Merge branch 'master' into scheduler_agent

Conflicts:
app/helpers/agent_helper.rb
db/schema.rb

Akinori MUSHA 10 years ago
parent
commit
806e41dd4c
76 changed files with 1518 additions and 277 deletions
  1. 14 0
      .env.example
  2. 1 1
      .travis.yml
  3. 7 0
      Gemfile
  4. 28 0
      Gemfile.lock
  5. 3 0
      app/assets/javascripts/application.js.coffee.erb
  6. 15 0
      app/assets/stylesheets/application.css.scss.erb
  7. 42 1
      app/concerns/liquid_droppable.rb
  8. 17 0
      app/concerns/liquid_interpolatable.rb
  9. 32 0
      app/concerns/oauthable.rb
  10. 6 4
      app/concerns/twitter_concern.rb
  11. 8 7
      app/controllers/agents_controller.rb
  12. 23 0
      app/controllers/application_controller.rb
  13. 2 0
      app/controllers/home_controller.rb
  14. 2 0
      app/controllers/scenarios_controller.rb
  15. 41 0
      app/controllers/services_controller.rb
  16. 1 1
      app/helpers/agent_helper.rb
  17. 23 0
      app/helpers/scenario_helper.rb
  18. 5 0
      app/helpers/service_helper.rb
  19. 3 12
      app/models/agent.rb
  20. 33 36
      app/models/agents/basecamp_agent.rb
  21. 15 3
      app/models/agents/event_formatting_agent.rb
  22. 1 1
      app/models/agents/google_calendar_publish_agent.rb
  23. 1 5
      app/models/agents/twitter_publish_agent.rb
  24. 1 5
      app/models/agents/twitter_stream_agent.rb
  25. 1 5
      app/models/agents/twitter_user_agent.rb
  26. 122 89
      app/models/agents/website_agent.rb
  27. 12 17
      app/models/event.rb
  28. 6 1
      app/models/scenario.rb
  29. 23 6
      app/models/scenario_import.rb
  30. 89 0
      app/models/service.rb
  31. 5 0
      app/models/user.rb
  32. 1 1
      app/views/agents/_action_menu.html.erb
  33. 5 1
      app/views/agents/_form.html.erb
  34. 6 0
      app/views/agents/_oauth_dropdown.html.erb
  35. 26 0
      app/views/application/_upgrade_warning.html.erb
  36. 1 0
      app/views/layouts/_navigation.html.erb
  37. 4 1
      app/views/layouts/application.html.erb
  38. 13 3
      app/views/scenario_imports/_step_two.html.erb
  39. 13 1
      app/views/scenarios/_form.html.erb
  40. 2 1
      app/views/scenarios/index.html.erb
  41. 2 2
      app/views/scenarios/share.html.erb
  42. 2 1
      app/views/scenarios/show.html.erb
  43. 58 0
      app/views/services/index.html.erb
  44. 3 3
      app/views/system_mailer/send_message.html.erb
  45. 5 0
      config/initializers/omniauth.rb
  46. 7 0
      config/routes.rb
  47. 18 0
      db/migrate/20140515211100_create_services.rb
  48. 5 0
      db/migrate/20140525150040_add_service_id_to_agents.rb
  49. 61 0
      db/migrate/20140525150140_migrate_agents_to_service_authentication.rb
  50. 5 0
      db/migrate/20140809211540_remove_service_index_on_user_id.rb
  51. 7 0
      db/migrate/20140811200922_add_uid_column_to_services.rb
  52. 6 0
      db/migrate/20140820003139_add_tag_color_to_scenarios.rb
  53. 59 34
      db/schema.rb
  54. 3 1
      lib/agents_exporter.rb
  55. 35 0
      spec/concerns/liquid_interpolatable_spec.rb
  56. 2 0
      spec/controllers/scenarios_controller_spec.rb
  57. 58 0
      spec/controllers/services_controller_spec.rb
  58. 43 0
      spec/data_fixtures/services/37signals.json
  59. 52 0
      spec/data_fixtures/services/github.json
  60. 66 0
      spec/data_fixtures/services/twitter.json
  61. 5 0
      spec/env.test
  62. 12 0
      spec/fixtures/agents.yml
  63. 17 0
      spec/fixtures/services.yml
  64. 30 0
      spec/helpers/scenario_helper_spec.rb
  65. 8 2
      spec/lib/agents_exporter_spec.rb
  66. 11 26
      spec/models/agents/basecamp_agent_spec.rb
  67. 1 0
      spec/models/agents/twitter_publish_agent_spec.rb
  68. 1 0
      spec/models/agents/twitter_stream_agent_spec.rb
  69. 2 0
      spec/models/agents/twitter_user_agent_spec.rb
  70. 17 1
      spec/models/agents/website_agent_spec.rb
  71. 29 0
      spec/models/concerns/oauthable.rb
  72. 9 0
      spec/models/event_spec.rb
  73. 66 3
      spec/models/scenario_import_spec.rb
  74. 24 0
      spec/models/scenario_spec.rb
  75. 129 0
      spec/models/service_spec.rb
  76. 7 2
      spec/spec_helper.rb

+ 14 - 0
.env.example

@@ -70,6 +70,20 @@ EMAIL_FROM_ADDRESS=from_address@gmail.com
70 70
 # Number of lines of log messages to keep per Agent
71 71
 AGENT_LOG_LENGTH=200
72 72
 
73
+########################################################################################################
74
+#    OAuth Configuration                                                                               #
75
+#  More information at the wiki: https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications #
76
+########################################################################################################
77
+
78
+TWITTER_OAUTH_KEY=
79
+TWITTER_OAUTH_SECRET=
80
+
81
+THIRTY_SEVEN_SIGNALS_OAUTH_KEY=
82
+THIRTY_SEVEN_SIGNALS_OAUTH_SECRET=
83
+
84
+GITHUB_OAUTH_KEY=
85
+GITHUB_OAUTH_SECRET=
86
+
73 87
 #############################
74 88
 #  AWS and Mechanical Turk  #
75 89
 #############################

+ 1 - 1
.travis.yml

@@ -8,7 +8,7 @@ rvm:
8 8
   - 2.1.1
9 9
   - 1.9.3
10 10
 before_install:
11
-  - travis_retry gem install bundler  
11
+  - travis_retry gem install bundler
12 12
 before_script:
13 13
   - mysql -e 'create database huginn_test;'
14 14
   - bundle exec rake db:migrate db:test:prepare

+ 7 - 0
Gemfile

@@ -56,6 +56,8 @@ gem 'uglifier', '>= 1.3.0'
56 56
 gem 'select2-rails', '~> 3.5.4'
57 57
 gem 'jquery-rails', '~> 3.1.0'
58 58
 gem 'ace-rails-ap', '~> 2.0.1'
59
+gem 'spectrum-rails'
60
+
59 61
 
60 62
 # geokit-rails doesn't work with geokit 1.8.X but it specifies ~> 1.5
61 63
 # in its own Gemfile.
@@ -86,6 +88,11 @@ gem 'slack-notifier', '~> 0.5.0'
86 88
 gem 'therubyracer', '~> 0.12.1'
87 89
 gem 'mqtt'
88 90
 
91
+gem 'omniauth'
92
+gem 'omniauth-twitter'
93
+gem 'omniauth-37signals'
94
+gem 'omniauth-github'
95
+
89 96
 group :development do
90 97
   gem 'binding_of_caller'
91 98
   gem 'better_errors'

+ 28 - 0
Gemfile.lock

@@ -191,12 +191,33 @@ GEM
191 191
     net-ftp-list (3.2.8)
192 192
     nokogiri (1.6.3.1)
193 193
       mini_portile (= 0.6.0)
194
+    oauth (0.4.7)
194 195
     oauth2 (0.9.4)
195 196
       faraday (>= 0.8, < 0.10)
196 197
       jwt (~> 1.0)
197 198
       multi_json (~> 1.3)
198 199
       multi_xml (~> 0.5)
199 200
       rack (~> 1.2)
201
+    omniauth (1.2.2)
202
+      hashie (>= 1.2, < 4)
203
+      rack (~> 1.0)
204
+    omniauth-37signals (1.0.5)
205
+      omniauth (~> 1.0)
206
+      omniauth-oauth2 (~> 1.0)
207
+    omniauth-github (1.1.2)
208
+      omniauth (~> 1.0)
209
+      omniauth-oauth2 (~> 1.1)
210
+    omniauth-oauth (1.0.1)
211
+      oauth
212
+      omniauth (~> 1.0)
213
+    omniauth-oauth2 (1.1.2)
214
+      faraday (>= 0.8, < 0.10)
215
+      multi_json (~> 1.3)
216
+      oauth2 (~> 0.9.3)
217
+      omniauth (~> 1.2)
218
+    omniauth-twitter (1.0.1)
219
+      multi_json (~> 1.3)
220
+      omniauth-oauth (~> 1.0)
200 221
     orm_adapter (0.5.0)
201 222
     pg (0.17.1)
202 223
     polyglot (0.3.5)
@@ -293,6 +314,8 @@ GEM
293 314
     simplecov-html (0.8.0)
294 315
     slack-notifier (0.5.0)
295 316
     slop (3.6.0)
317
+    spectrum-rails (1.3.4)
318
+      railties (>= 3.1)
296 319
     sprockets (2.11.0)
297 320
       hike (~> 1.2)
298 321
       multi_json (~> 1.0)
@@ -400,6 +423,10 @@ DEPENDENCIES
400 423
   mysql2 (~> 0.3.16)
401 424
   net-ftp-list (~> 3.2.8)
402 425
   nokogiri (~> 1.6.1)
426
+  omniauth
427
+  omniauth-37signals
428
+  omniauth-github
429
+  omniauth-twitter
403 430
   pg
404 431
   protected_attributes (~> 1.0.8)
405 432
   pry
@@ -418,6 +445,7 @@ DEPENDENCIES
418 445
   select2-rails (~> 3.5.4)
419 446
   shoulda-matchers
420 447
   slack-notifier (~> 0.5.0)
448
+  spectrum-rails
421 449
   therubyracer (~> 0.12.1)
422 450
   twilio-ruby (~> 3.11.5)
423 451
   twitter (~> 5.8.0)

+ 3 - 0
app/assets/javascripts/application.js.coffee.erb

@@ -6,6 +6,7 @@
6 6
 #= require json2
7 7
 #= require jquery.json-editor
8 8
 #= require latlon_and_geo
9
+#= require spectrum
9 10
 #= require ./worker-checker
10 11
 #= require_self
11 12
 
@@ -182,6 +183,8 @@ $(document).ready ->
182 183
 
183 184
         $(".description").html(json.description_html) if json.description_html?
184 185
 
186
+        $('.oauthable-form').html(json.form) if json.form?
187
+
185 188
         if $("#agent_options").hasClass("showing-default") || $("#agent_options").val().match(/\A\s*(\{\s*\}|)\s*\Z/g)
186 189
           window.jsonEditor.json = json.options
187 190
           window.jsonEditor.rebuild()

+ 15 - 0
app/assets/stylesheets/application.css.scss.erb

@@ -12,6 +12,7 @@
12 12
  *= require select2-bootstrap
13 13
  *= require jquery.json-editor
14 14
  *= require rickshaw
15
+ *= require spectrum
15 16
  *= require_tree .
16 17
  *= require_self
17 18
  */
@@ -186,3 +187,17 @@ h2 .scenario, a span.label.scenario {
186 187
 .color-success {
187 188
   color: #5cb85c;
188 189
 }
190
+
191
+.form-group {
192
+  .sp-replacer {
193
+    @extend .form-control;
194
+  }
195
+
196
+  .sp-preview {
197
+    width: 100%;
198
+  }
199
+
200
+  .sp-dd {
201
+    display: none;
202
+  }
203
+}

+ 42 - 1
app/concerns/liquid_droppable.rb

@@ -1,9 +1,50 @@
1 1
 module LiquidDroppable
2 2
   extend ActiveSupport::Concern
3 3
 
4
+  # In subclasses of this base class, "locals" take precedence over
5
+  # methods.
4 6
   class Drop < Liquid::Drop
5
-    def initialize(object)
7
+    class << self
8
+      def inherited(subclass)
9
+        class << subclass
10
+          attr_reader :drop_methods
11
+
12
+          # Make all public methods private so that #before_method
13
+          # catches everything.
14
+          def drop_methods!
15
+            return if @drop_methods
16
+
17
+            @drop_methods = Set.new
18
+
19
+            (public_instance_methods - Drop.public_instance_methods).each { |name|
20
+              @drop_methods << name.to_s
21
+              private name
22
+            }
23
+          end
24
+        end
25
+      end
26
+    end
27
+
28
+    def initialize(object, locals = nil)
29
+      self.class.drop_methods!
30
+
6 31
       @object = object
32
+      @locals = locals || {}
33
+    end
34
+
35
+    def before_method(name)
36
+      if @locals.include?(name)
37
+        @locals[name]
38
+      elsif self.class.drop_methods.include?(name)
39
+        __send__(name)
40
+      end
41
+    end
42
+
43
+    def each
44
+      return to_enum(__method__) unless block_given?
45
+      self.class.drop_methods.each { |name|
46
+        yield [name, __send__(name)]
47
+      }
7 48
     end
8 49
   end
9 50
 

+ 17 - 0
app/concerns/liquid_interpolatable.rb

@@ -1,6 +1,23 @@
1 1
 module LiquidInterpolatable
2 2
   extend ActiveSupport::Concern
3 3
 
4
+  included do
5
+    validate :validate_interpolation
6
+  end
7
+
8
+  def valid?(context = nil)
9
+    super
10
+  rescue Liquid::Error
11
+    errors.empty?
12
+  end
13
+
14
+  def validate_interpolation
15
+    interpolated
16
+  rescue Liquid::Error => e
17
+    errors.add(:options, "has an error with Liquid templating: #{e.message}")
18
+    false
19
+  end
20
+
4 21
   def interpolate_options(options, event = {})
5 22
     case options
6 23
       when String

+ 32 - 0
app/concerns/oauthable.rb

@@ -0,0 +1,32 @@
1
+module Oauthable
2
+  extend ActiveSupport::Concern
3
+
4
+  included do |base|
5
+    @valid_oauth_providers = :all
6
+    attr_accessible :service_id
7
+    validates_presence_of :service_id
8
+  end
9
+
10
+  def oauthable?
11
+    true
12
+  end
13
+
14
+  def valid_services_for(user)
15
+    if valid_oauth_providers == :all
16
+      user.available_services
17
+    else
18
+      user.available_services.where(provider: valid_oauth_providers)
19
+    end
20
+  end
21
+
22
+  def valid_oauth_providers
23
+    self.class.valid_oauth_providers
24
+  end
25
+
26
+  module ClassMethods
27
+    def valid_oauth_providers(*providers)
28
+      return @valid_oauth_providers if providers == []
29
+      @valid_oauth_providers = providers
30
+    end
31
+  end
32
+end

+ 6 - 4
app/concerns/twitter_concern.rb

@@ -1,8 +1,10 @@
1 1
 module TwitterConcern
2 2
   extend ActiveSupport::Concern
3
+  include Oauthable
3 4
 
4 5
   included do
5 6
     validate :validate_twitter_options
7
+    valid_oauth_providers :twitter
6 8
   end
7 9
 
8 10
   def validate_twitter_options
@@ -15,19 +17,19 @@ module TwitterConcern
15 17
   end
16 18
 
17 19
   def twitter_consumer_key
18
-    options['consumer_key'].presence || credential('twitter_consumer_key')
20
+    ENV['TWITTER_OAUTH_KEY']
19 21
   end
20 22
 
21 23
   def twitter_consumer_secret
22
-    options['consumer_secret'].presence || credential('twitter_consumer_secret')
24
+    ENV['TWITTER_OAUTH_SECRET']
23 25
   end
24 26
 
25 27
   def twitter_oauth_token
26
-    options['oauth_token'].presence || options['access_key'].presence || credential('twitter_oauth_token')
28
+    service.token
27 29
   end
28 30
 
29 31
   def twitter_oauth_token_secret
30
-    options['oauth_token_secret'].presence || options['access_secret'].presence || credential('twitter_oauth_token_secret')
32
+    service.secret
31 33
   end
32 34
 
33 35
   def twitter

+ 8 - 7
app/controllers/agents_controller.rb

@@ -31,14 +31,15 @@ class AgentsController < ApplicationController
31 31
   end
32 32
 
33 33
   def type_details
34
-    agent = Agent.build_for_type(params[:type], current_user, {})
34
+    @agent = Agent.build_for_type(params[:type], current_user, {})
35 35
     render :json => {
36
-        :can_be_scheduled => agent.can_be_scheduled?,
37
-        :default_schedule => agent.default_schedule,
38
-        :can_receive_events => agent.can_receive_events?,
39
-        :can_create_events => agent.can_create_events?,
40
-        :options => agent.default_options,
41
-        :description_html => agent.html_description
36
+        :can_be_scheduled => @agent.can_be_scheduled?,
37
+        :default_schedule => @agent.default_schedule,
38
+        :can_receive_events => @agent.can_receive_events?,
39
+        :can_create_events => @agent.can_create_events?,
40
+        :options => @agent.default_options,
41
+        :description_html => @agent.html_description,
42
+        :form => render_to_string(partial: 'oauth_dropdown')
42 43
     }
43 44
   end
44 45
 

+ 23 - 0
app/controllers/application_controller.rb

@@ -13,4 +13,27 @@ class ApplicationController < ActionController::Base
13 13
     devise_parameter_sanitizer.for(:sign_in) { |u| u.permit(:login, :username, :email, :password, :remember_me) }
14 14
     devise_parameter_sanitizer.for(:account_update) { |u| u.permit(:username, :email, :password, :password_confirmation, :current_password) }
15 15
   end
16
+
17
+  def upgrade_warning
18
+    return unless current_user
19
+    twitter_oauth_check
20
+    basecamp_auth_check
21
+  end
22
+
23
+  private
24
+  def twitter_oauth_check
25
+    if ENV['TWITTER_OAUTH_KEY'].blank? || ENV['TWITTER_OAUTH_SECRET'].blank?
26
+      if @twitter_agent = current_user.agents.where("type like 'Agents::Twitter%'").first
27
+        @twitter_oauth_key    = @twitter_agent.options['consumer_key'].presence || @twitter_agent.credential('twitter_consumer_key')
28
+        @twitter_oauth_secret = @twitter_agent.options['consumer_secret'].presence || @twitter_agent.credential('twitter_consumer_secret')
29
+      end
30
+    end
31
+  end
32
+
33
+  def basecamp_auth_check
34
+    if ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'].blank? || ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET'].blank?
35
+      @basecamp_agent = current_user.agents.where(type: 'Agents::BasecampAgent').first
36
+    end
37
+  end
38
+
16 39
 end

+ 2 - 0
app/controllers/home_controller.rb

@@ -1,6 +1,8 @@
1 1
 class HomeController < ApplicationController
2 2
   skip_before_filter :authenticate_user!
3 3
 
4
+  before_filter :upgrade_warning, only: :index
5
+
4 6
   def index
5 7
   end
6 8
 

+ 2 - 0
app/controllers/scenarios_controller.rb

@@ -45,6 +45,8 @@ class ScenariosController < ApplicationController
45 45
     @exporter = AgentsExporter.new(:name => @scenario.name,
46 46
                                    :description => @scenario.description,
47 47
                                    :guid => @scenario.guid,
48
+                                   :tag_fg_color => @scenario.tag_fg_color,
49
+                                   :tag_bg_color => @scenario.tag_bg_color,
48 50
                                    :source_url => @scenario.public? && export_scenario_url(@scenario),
49 51
                                    :agents => @scenario.agents)
50 52
     response.headers['Content-Disposition'] = 'attachment; filename="' + @exporter.filename + '"'

+ 41 - 0
app/controllers/services_controller.rb

@@ -0,0 +1,41 @@
1
+class ServicesController < ApplicationController
2
+  before_filter :upgrade_warning, only: :index
3
+
4
+  def index
5
+    @services = current_user.services.page(params[:page])
6
+
7
+    respond_to do |format|
8
+      format.html
9
+      format.json { render json: @services }
10
+    end
11
+  end
12
+
13
+  def destroy
14
+    @services = current_user.services.find(params[:id])
15
+    @services.destroy
16
+
17
+    respond_to do |format|
18
+      format.html { redirect_to services_path }
19
+      format.json { head :no_content }
20
+    end
21
+  end
22
+
23
+  def toggle_availability
24
+    @service = current_user.services.find(params[:id])
25
+    @service.toggle_availability!
26
+
27
+    respond_to do |format|
28
+      format.html { redirect_to services_path }
29
+      format.json { render json: @service }
30
+    end
31
+  end
32
+
33
+  def callback
34
+    @service = current_user.services.initialize_or_update_via_omniauth(request.env['omniauth.auth'])
35
+    if @service && @service.save
36
+      redirect_to services_path, notice: "The service was successfully created."
37
+    else
38
+      redirect_to services_path, error: "Error creating the service."
39
+    end
40
+  end
41
+end

+ 1 - 1
app/helpers/agent_helper.rb

@@ -8,7 +8,7 @@ module AgentHelper
8 8
 
9 9
   def scenario_links(agent)
10 10
     agent.scenarios.map { |scenario|
11
-      link_to(scenario.name, scenario, class: "label label-info")
11
+      link_to(scenario.name, scenario, class: "label", style: style_colors(scenario))
12 12
     }.join(" ").html_safe
13 13
   end
14 14
 

+ 23 - 0
app/helpers/scenario_helper.rb

@@ -0,0 +1,23 @@
1
+module ScenarioHelper
2
+
3
+  def style_colors(scenario)
4
+    colors = {
5
+      color: scenario.tag_fg_color || default_scenario_fg_color,
6
+      background_color: scenario.tag_bg_color || default_scenario_bg_color
7
+    }.map { |key, value| "#{key.to_s.dasherize}:#{value}" }.join(';')
8
+  end
9
+
10
+  def scenario_label(scenario, text = nil)
11
+    text ||= scenario.name
12
+    content_tag :span, text, class: 'label scenario', style: style_colors(scenario)
13
+  end
14
+
15
+  def default_scenario_bg_color
16
+    '#5BC0DE'
17
+  end
18
+
19
+  def default_scenario_fg_color
20
+    '#FFFFFF'
21
+  end
22
+
23
+end

+ 5 - 0
app/helpers/service_helper.rb

@@ -0,0 +1,5 @@
1
+module ServiceHelper
2
+  def has_oauth_configuration_for(provider)
3
+    ENV["#{provider.upcase}_OAUTH_KEY"].present? && ENV["#{provider.upcase}_OAUTH_SECRET"].present?
4
+  end
5
+end

+ 3 - 12
app/models/agent.rb

@@ -46,6 +46,7 @@ class Agent < ActiveRecord::Base
46 46
   after_save :possibly_update_event_expirations
47 47
 
48 48
   belongs_to :user, :inverse_of => :agents
49
+  belongs_to :service, :inverse_of => :agents
49 50
   has_many :events, -> { order("events.id desc") }, :dependent => :delete_all, :inverse_of => :agent
50 51
   has_one  :most_recent_event, :inverse_of => :agent, :class_name => "Event", :order => "events.id desc"
51 52
   has_many :logs,  -> { order("agent_logs.id desc") }, :dependent => :delete_all, :inverse_of => :agent, :class_name => "AgentLog"
@@ -413,7 +414,7 @@ class AgentDrop
413 414
     @object.short_type
414 415
   end
415 416
 
416
-  METHODS = [
417
+  [
417 418
     :name,
418 419
     :type,
419 420
     :options,
@@ -426,19 +427,9 @@ class AgentDrop
426 427
     :disabled,
427 428
     :keep_events_for,
428 429
     :propagate_immediately,
429
-  ]
430
-
431
-  METHODS.each { |attr|
430
+  ].each { |attr|
432 431
     define_method(attr) {
433 432
       @object.__send__(attr)
434 433
     } unless method_defined?(attr)
435 434
   }
436
-
437
-  def each(&block)
438
-    return to_enum(__method__) unless block
439
-
440
-    METHODS.each { |attr|
441
-      yield [attr, __sent__(attr)]
442
-    }
443
-  end
444 435
 end

+ 33 - 36
app/models/agents/basecamp_agent.rb

@@ -2,17 +2,18 @@ module Agents
2 2
   class BasecampAgent < Agent
3 3
     cannot_receive_events!
4 4
 
5
+    include Oauthable
6
+    valid_oauth_providers '37signals'
7
+
5 8
     description <<-MD
6 9
       The BasecampAgent checks a Basecamp project for new Events
7 10
 
8
-      It is required that you enter your Basecamp credentials (`username` and `password`).
11
+      To be able to use this Agent you need to authenticate with 37signals in the [Services](/services) section first.
9 12
 
10
-      You also need to provide your Basecamp `user_id` and the `project_id` of the project you want to monitor.
13
+      You need to provide the `project_id` of the project you want to monitor.
11 14
       If you have your Basecamp project opened in your browser you can find the user_id and project_id as follows:
12 15
 
13
-      `https://basecamp.com/`
14
-      user_id
15
-      `/projects/`
16
+      `https://basecamp.com/123456/projects/`
16 17
       project_id
17 18
       `-explore-basecamp`
18 19
     MD
@@ -20,42 +21,36 @@ module Agents
20 21
     event_description <<-MD
21 22
       Events are the raw JSON provided by the Basecamp API. Should look something like:
22 23
 
23
-        {
24
-          "creator": {
25
-            "fullsize_avatar_url": "https://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/original.gif?r=3",
26
-            "avatar_url": "http://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/avatar.gif?r=3",
27
-            "name": "Dominik Sander",
28
-            "id": 123456
29
-          },
30
-          "attachments": [],
31
-          "raw_excerpt": "test test",
32
-          "excerpt": "test test",
33
-          "id": 6454342343,
34
-          "created_at": "2014-04-17T10:25:31.000+02:00",
35
-          "updated_at": "2014-04-17T10:25:31.000+02:00",
36
-          "summary": "commented on whaat",
37
-          "action": "commented on",
38
-          "target": "whaat",
39
-          "url": "https://basecamp.com/12456/api/v1/projects/76454545-explore-basecamp/messages/76454545-whaat.json",
40
-          "html_url": "https://basecamp.com/12456/projects/76454545-explore-basecamp/messages/76454545-whaat#comment_76454545"
41
-        }
24
+          {
25
+            "creator": {
26
+              "fullsize_avatar_url": "https://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/original.gif?r=3",
27
+              "avatar_url": "http://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/avatar.gif?r=3",
28
+              "name": "Dominik Sander",
29
+              "id": 123456
30
+            },
31
+            "attachments": [],
32
+            "raw_excerpt": "test test",
33
+            "excerpt": "test test",
34
+            "id": 6454342343,
35
+            "created_at": "2014-04-17T10:25:31.000+02:00",
36
+            "updated_at": "2014-04-17T10:25:31.000+02:00",
37
+            "summary": "commented on whaat",
38
+            "action": "commented on",
39
+            "target": "whaat",
40
+            "url": "https://basecamp.com/12456/api/v1/projects/76454545-explore-basecamp/messages/76454545-whaat.json",
41
+            "html_url": "https://basecamp.com/12456/projects/76454545-explore-basecamp/messages/76454545-whaat#comment_76454545"
42
+          }
42 43
     MD
43 44
 
44 45
     default_schedule "every_10m"
45 46
 
46 47
     def default_options
47 48
       {
48
-        'username' => '',
49
-        'password' => '',
50
-        'user_id' => '',
51 49
         'project_id' => '',
52 50
       }
53 51
     end
54 52
 
55 53
     def validate_options
56
-      errors.add(:base, "you need to specify your basecamp username") unless options['username'].present?
57
-      errors.add(:base, "you need to specify your basecamp password") unless options['password'].present?
58
-      errors.add(:base, "you need to specify your basecamp user id") unless options['user_id'].present?
59 54
       errors.add(:base, "you need to specify the basecamp project id of which you want to receive events") unless options['project_id'].present?
60 55
     end
61 56
 
@@ -64,27 +59,29 @@ module Agents
64 59
     end
65 60
 
66 61
     def check
62
+      service.prepare_request
67 63
       reponse = HTTParty.get request_url, request_options.merge(query_parameters)
68
-      memory[:last_run] = Time.now.utc.iso8601
69
-      if last_check_at != nil
70
-        JSON.parse(reponse.body).each do |event|
64
+      events = JSON.parse(reponse.body)
65
+      if !memory[:last_event].nil?
66
+        events.each do |event|
71 67
           create_event :payload => event
72 68
         end
73 69
       end
70
+      memory[:last_event] = events.first['created_at'] if events.length > 0
74 71
       save!
75 72
     end
76 73
 
77 74
   private
78 75
     def request_url
79
-      "https://basecamp.com/#{URI.encode(interpolated[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
76
+      "https://basecamp.com/#{URI.encode(service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
80 77
     end
81 78
 
82 79
     def request_options
83
-      {:basic_auth => {:username => interpolated[:username], :password => interpolated[:password]}, :headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}}
80
+      {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => "Bearer \"#{service.token}\""}}
84 81
     end
85 82
 
86 83
     def query_parameters
87
-      memory[:last_run].present? ? { :query => {:since => memory[:last_run]} } : {}
84
+      memory[:last_event].present? ? { :query => {:since => memory[:last_event]} } : {}
88 85
     end
89 86
   end
90 87
 end

+ 15 - 3
app/models/agents/event_formatting_agent.rb

@@ -51,7 +51,7 @@ module Agents
51 51
               {
52 52
                 "path": "{{date.pretty}}",
53 53
                 "regexp": "\\A(?<time>\\d\\d:\\d\\d [AP]M [A-Z]+)",
54
-                "to": "pretty_date",
54
+                "to": "pretty_date"
55 55
               }
56 56
             ]
57 57
           }
@@ -61,7 +61,7 @@ module Agents
61 61
           "pretty_date": {
62 62
             "time": "10:00 PM EST",
63 63
             "0": "10:00 PM EST on January 11, 2013"
64
-            "1": "10:00 PM EST",
64
+            "1": "10:00 PM EST"
65 65
           }
66 66
 
67 67
       So you can use it in `instructions` like this:
@@ -80,7 +80,19 @@ module Agents
80 80
           }
81 81
     MD
82 82
 
83
-    event_description "User defined"
83
+    event_description do
84
+      "Events will have the following fields%s:\n\n    %s" % [
85
+        case options['mode'].to_s
86
+        when 'merged'
87
+          ', merged with the original contents'
88
+        when /\{/
89
+          ', conditionally merged with the original contents'
90
+        end,
91
+        Utils.pretty_print(Hash[options['instructions'].keys.map { |key|
92
+          [key, "..."]
93
+        }])
94
+      ]
95
+    end
84 96
 
85 97
     after_save :clear_matchers
86 98
 

+ 1 - 1
app/models/agents/google_calendar_publish_agent.rb

@@ -62,7 +62,7 @@ module Agents
62 62
            ....
63 63
         },
64 64
         'agent_id' => 1234,
65
-        'event_id' => 3432,
65
+        'event_id' => 3432
66 66
       }
67 67
     MD
68 68
 

+ 1 - 5
app/models/agents/twitter_publish_agent.rb

@@ -9,11 +9,7 @@ module Agents
9 9
     description <<-MD
10 10
       The TwitterPublishAgent publishes tweets from the events it receives.
11 11
 
12
-      Twitter credentials must be supplied as either [credentials](/user_credentials) called
13
-      `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`,
14
-      or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`.
15
-
16
-      To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token).
12
+      To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.
17 13
 
18 14
       You must also specify a `message` parameter, you can use [Liquid](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to format the message.
19 15
 

+ 1 - 5
app/models/agents/twitter_stream_agent.rb

@@ -10,11 +10,7 @@ module Agents
10 10
       To follow the Twitter stream, provide an array of `filters`.  Multiple words in a filter must all show up in a tweet, but are independent of order.
11 11
       If you provide an array instead of a filter, the first entry will be considered primary and any additional values will be treated as aliases.
12 12
 
13
-      Twitter credentials must be supplied as either [credentials](/user_credentials) called
14
-      `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`,
15
-      or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`.
16
-
17
-      To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token).
13
+      To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.
18 14
 
19 15
       Set `expected_update_period_in_days` to the maximum amount of time that you'd expect to pass between Events being created by this Agent.
20 16
 

+ 1 - 5
app/models/agents/twitter_user_agent.rb

@@ -9,11 +9,7 @@ module Agents
9 9
     description <<-MD
10 10
       The TwitterUserAgent follows the timeline of a specified Twitter user.
11 11
 
12
-      Twitter credentials must be supplied as either [credentials](/user_credentials) called
13
-      `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`,
14
-      or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`.
15
-
16
-      To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token).
12
+      To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.
17 13
 
18 14
       You must also provide the `username` of the Twitter user to monitor.
19 15
 

+ 122 - 89
app/models/agents/website_agent.rb

@@ -42,20 +42,20 @@ module Agents
42 42
 
43 43
           "extract": {
44 44
             "word": { "regexp": "^(.+?): (.+)$", index: 1 },
45
-            "definition": { "regexp": "^(.+?): (.+)$", index: 2 },
45
+            "definition": { "regexp": "^(.+?): (.+)$", index: 2 }
46 46
           }
47 47
 
48 48
       Or if you prefer names to numbers for index:
49 49
 
50 50
           "extract": {
51 51
             "word": { "regexp": "^(?<word>.+?): (?<definition>.+)$", index: 'word' },
52
-            "definition": { "regexp": "^(?<word>.+?): (?<definition>.+)$", index: 'definition' },
52
+            "definition": { "regexp": "^(?<word>.+?): (?<definition>.+)$", index: 'definition' }
53 53
           }
54 54
 
55 55
       To extract the whole content as one event:
56 56
 
57 57
           "extract": {
58
-            "content": { "regexp": "\A(?m:.)*\z", index: 0 },
58
+            "content": { "regexp": "\A(?m:.)*\z", index: 0 }
59 59
           }
60 60
 
61 61
       Beware that `.` does not match the newline character (LF) unless the `m` flag is in effect, and `^`/`$` basically match every line beginning/end.  See [this document](http://ruby-doc.org/core-#{RUBY_VERSION}/doc/regexp_rdoc.html) to learn the regular expression variant used in this service.
@@ -78,7 +78,11 @@ module Agents
78 78
     MD
79 79
 
80 80
     event_description do
81
-      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print interpolated['extract']}"
81
+      "Events will have the following fields:\n\n    %s" % [
82
+        Utils.pretty_print(Hash[options['extract'].keys.map { |key|
83
+          [key, "..."]
84
+        }])
85
+      ]
82 86
     end
83 87
 
84 88
     def working?
@@ -157,85 +161,60 @@ module Agents
157 161
               log "Storing new result for '#{name}': #{doc.inspect}"
158 162
               create_event :payload => doc
159 163
             end
160
-          else
161
-            output = {}
162
-            interpolated['extract'].each do |name, extraction_details|
163
-              case extraction_type
164
-              when "text"
165
-                regexp = Regexp.new(extraction_details['regexp'])
166
-                result = []
167
-                doc.scan(regexp) {
168
-                  result << Regexp.last_match[extraction_details['index']]
169
-                }
170
-                log "Extracting #{extraction_type} at #{regexp}: #{result}"
171
-              when "json"
172
-                result = Utils.values_at(doc, extraction_details['path'])
173
-                log "Extracting #{extraction_type} at #{extraction_details['path']}: #{result}"
174
-              else
175
-                case
176
-                when css = extraction_details['css']
177
-                  nodes = doc.css(css)
178
-                when xpath = extraction_details['xpath']
179
-                  doc.remove_namespaces! # ignore xmlns, useful when parsing atom feeds
180
-                  nodes = doc.xpath(xpath)
181
-                else
182
-                  error '"css" or "xpath" is required for HTML or XML extraction'
183
-                  return
184
-                end
185
-                case nodes
186
-                when Nokogiri::XML::NodeSet
187
-                  result = nodes.map { |node|
188
-                    case value = node.xpath(extraction_details['value'])
189
-                    when Float
190
-                      # Node#xpath() returns any numeric value as float;
191
-                      # convert it to integer as appropriate.
192
-                      value = value.to_i if value.to_i == value
193
-                    end
194
-                    value.to_s
195
-                  }
196
-                else
197
-                  error "The result of HTML/XML extraction was not a NodeSet"
198
-                  return
199
-                end
200
-                log "Extracting #{extraction_type} at #{xpath || css}: #{result}"
201
-              end
202
-              output[name] = result
164
+            next
165
+          end
166
+
167
+          output =
168
+            case extraction_type
169
+            when 'json'
170
+              extract_json(doc)
171
+            when 'text'
172
+              extract_text(doc)
173
+            else
174
+              extract_xml(doc)
203 175
             end
204 176
 
205
-            num_unique_lengths = interpolated['extract'].keys.map { |name| output[name].length }.uniq
177
+          num_unique_lengths = interpolated['extract'].keys.map { |name| output[name].length }.uniq
206 178
 
207
-            if num_unique_lengths.length != 1
208
-              error "Got an uneven number of matches for #{interpolated['name']}: #{interpolated['extract'].inspect}"
209
-              return
210
-            end
179
+          if num_unique_lengths.length != 1
180
+            raise "Got an uneven number of matches for #{interpolated['name']}: #{interpolated['extract'].inspect}"
181
+          end
211 182
 
212
-            old_events = previous_payloads num_unique_lengths.first
213
-            num_unique_lengths.first.times do |index|
214
-              result = {}
215
-              interpolated['extract'].keys.each do |name|
216
-                result[name] = output[name][index]
217
-                if name.to_s == 'url'
218
-                  result[name] = (response.env[:url] + result[name]).to_s
219
-                end
183
+          old_events = previous_payloads num_unique_lengths.first
184
+          num_unique_lengths.first.times do |index|
185
+            result = {}
186
+            interpolated['extract'].keys.each do |name|
187
+              result[name] = output[name][index]
188
+              if name.to_s == 'url'
189
+                result[name] = (response.env[:url] + result[name]).to_s
220 190
               end
191
+            end
221 192
 
222
-              if store_payload!(old_events, result)
223
-                log "Storing new parsed result for '#{name}': #{result.inspect}"
224
-                create_event :payload => result
225
-              end
193
+            if store_payload!(old_events, result)
194
+              log "Storing new parsed result for '#{name}': #{result.inspect}"
195
+              create_event :payload => result
226 196
             end
227 197
           end
228 198
         else
229
-          error "Failed: #{response.inspect}"
199
+          raise "Failed: #{response.inspect}"
230 200
         end
231 201
       end
202
+    rescue => e
203
+      error e.message
232 204
     end
233 205
 
234 206
     def receive(incoming_events)
235 207
       incoming_events.each do |event|
208
+        Thread.current[:current_event] = event
236 209
         url_to_scrape = event.payload['url']
237 210
         check_url(url_to_scrape) if url_to_scrape =~ /^https?:\/\//i
238 211
       end
212
+    ensure
213
+      Thread.current[:current_event] = nil
214
+    end
215
+
216
+    def interpolated(event = Thread.current[:current_event])
217
+      super
239 218
     end
240 219
 
241 220
     private
@@ -244,22 +223,22 @@ module Agents
244 223
     # If mode is set to 'on_change', this method may return false and update an existing
245 224
     # event to expire further in the future.
246 225
     def store_payload!(old_events, result)
247
-      if !interpolated['mode'].present?
248
-        return true
249
-      elsif interpolated['mode'].to_s == "all"
250
-        return true
251
-      elsif interpolated['mode'].to_s == "on_change"
226
+      case interpolated['mode'].presence
227
+      when 'on_change'
252 228
         result_json = result.to_json
253 229
         old_events.each do |old_event|
254 230
           if old_event.payload.to_json == result_json
255 231
             old_event.expires_at = new_event_expiration_date
256 232
             old_event.save!
257 233
             return false
258
-         end
234
+          end
259 235
         end
260
-        return true
236
+        true
237
+      when 'all', ''
238
+        true
239
+      else
240
+        raise "Illegal options[mode]: #{interpolated['mode']}"
261 241
       end
262
-      raise "Illegal options[mode]: " + interpolated['mode'].to_s
263 242
     end
264 243
 
265 244
     def previous_payloads(num_events)
@@ -272,7 +251,7 @@ module Agents
272 251
           look_back = UNIQUENESS_LOOK_BACK
273 252
         end
274 253
       end
275
-      events.order("id desc").limit(look_back) if interpolated['mode'].present? && interpolated['mode'].to_s == "on_change"
254
+      events.order("id desc").limit(look_back) if interpolated['mode'] == "on_change"
276 255
     end
277 256
 
278 257
     def extract_full_json?
@@ -294,27 +273,81 @@ module Agents
294 273
       end).to_s
295 274
     end
296 275
 
276
+    def extract_each(doc, &block)
277
+      interpolated['extract'].each_with_object({}) { |(name, extraction_details), output|
278
+        output[name] = block.call(extraction_details)
279
+      }
280
+    end
281
+
282
+    def extract_json(doc)
283
+      extract_each(doc) { |extraction_details|
284
+        result = Utils.values_at(doc, extraction_details['path'])
285
+        log "Extracting #{extraction_type} at #{extraction_details['path']}: #{result}"
286
+        result
287
+      }
288
+    end
289
+
290
+    def extract_text(doc)
291
+      extract_each(doc) { |extraction_details|
292
+        regexp = Regexp.new(extraction_details['regexp'])
293
+        result = []
294
+        doc.scan(regexp) {
295
+          result << Regexp.last_match[extraction_details['index']]
296
+        }
297
+        log "Extracting #{extraction_type} at #{regexp}: #{result}"
298
+        result
299
+      }
300
+    end
301
+
302
+    def extract_xml(doc)
303
+      extract_each(doc) { |extraction_details|
304
+        case
305
+        when css = extraction_details['css']
306
+          nodes = doc.css(css)
307
+        when xpath = extraction_details['xpath']
308
+          doc.remove_namespaces! # ignore xmlns, useful when parsing atom feeds
309
+          nodes = doc.xpath(xpath)
310
+        else
311
+          raise '"css" or "xpath" is required for HTML or XML extraction'
312
+        end
313
+        case nodes
314
+        when Nokogiri::XML::NodeSet
315
+          result = nodes.map { |node|
316
+            case value = node.xpath(extraction_details['value'])
317
+            when Float
318
+              # Node#xpath() returns any numeric value as float;
319
+              # convert it to integer as appropriate.
320
+              value = value.to_i if value.to_i == value
321
+            end
322
+            value.to_s
323
+          }
324
+        else
325
+          raise "The result of HTML/XML extraction was not a NodeSet"
326
+        end
327
+        log "Extracting #{extraction_type} at #{xpath || css}: #{result}"
328
+        result
329
+      }
330
+    end
331
+
297 332
     def parse(data)
298 333
       case extraction_type
299
-        when "xml"
300
-          Nokogiri::XML(data)
301
-        when "json"
302
-          JSON.parse(data)
303
-        when "html"
304
-          Nokogiri::HTML(data)
305
-        when "text"
306
-          data
307
-        else
308
-          raise "Unknown extraction type #{extraction_type}"
334
+      when "xml"
335
+        Nokogiri::XML(data)
336
+      when "json"
337
+        JSON.parse(data)
338
+      when "html"
339
+        Nokogiri::HTML(data)
340
+      when "text"
341
+        data
342
+      else
343
+        raise "Unknown extraction type #{extraction_type}"
309 344
       end
310 345
     end
311 346
 
312 347
     def is_positive_integer?(value)
313
-      begin
314
-        Integer(value) >= 0
315
-      rescue
316
-        false
317
-      end
348
+      Integer(value) >= 0
349
+    rescue
350
+      false
318 351
     end
319 352
   end
320 353
 end

+ 12 - 17
app/models/event.rb

@@ -44,26 +44,21 @@ class Event < ActiveRecord::Base
44 44
 end
45 45
 
46 46
 class EventDrop
47
-  def initialize(event, payload = event.payload)
48
-    super(event)
49
-    @payload = payload
50
-  end
51
-
52
-  def before_method(key)
53
-    if @payload.key?(key)
54
-      @payload[key]
55
-    else
56
-      case key
57
-      when 'agent'
58
-        @object.agent
59
-      when 'created_at'
60
-        @object.created_at
61
-      end
62
-    end
47
+  def initialize(object, locals = nil)
48
+    locals = object.payload.merge(locals || {})
49
+    super
63 50
   end
64 51
 
65 52
   def each(&block)
66 53
     return to_enum(__method__) unless block
67
-    @payload.each(&block)
54
+    @locals.each(&block)
55
+  end
56
+
57
+  def agent
58
+    @object.agent
59
+  end
60
+
61
+  def created_at
62
+    @object.created_at
68 63
   end
69 64
 end

+ 6 - 1
app/models/scenario.rb

@@ -1,7 +1,7 @@
1 1
 class Scenario < ActiveRecord::Base
2 2
   include HasGuid
3 3
 
4
-  attr_accessible :name, :agent_ids, :description, :public, :source_url
4
+  attr_accessible :name, :agent_ids, :description, :public, :source_url, :tag_fg_color, :tag_bg_color
5 5
 
6 6
   belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios
7 7
   has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario
@@ -9,6 +9,11 @@ class Scenario < ActiveRecord::Base
9 9
 
10 10
   validates_presence_of :name, :user
11 11
 
12
+  validates_format_of :tag_fg_color, :tag_bg_color,
13
+    # Regex adapted from: http://stackoverflow.com/a/1636354/3130625
14
+    :with => /\A#(?:[0-9a-fA-F]{3}){1,2}\z/, :allow_nil => true,
15
+    :message => "must be a valid hex color."
16
+
12 17
   validate :agents_are_owned
13 18
 
14 19
   protected

+ 23 - 6
app/models/scenario_import.rb

@@ -60,10 +60,14 @@ class ScenarioImport
60 60
     description = parsed_data['description']
61 61
     name = parsed_data['name']
62 62
     links = parsed_data['links']
63
+    tag_fg_color = parsed_data['tag_fg_color']
64
+    tag_bg_color = parsed_data['tag_bg_color']
63 65
     source_url = parsed_data['source_url'].presence || nil
64 66
     @scenario = user.scenarios.where(:guid => guid).first_or_initialize
65 67
     @scenario.update_attributes!(:name => name, :description => description,
66
-                                 :source_url => source_url, :public => false)
68
+                                 :source_url => source_url, :public => false,
69
+                                 :tag_fg_color => tag_fg_color,
70
+                                 :tag_bg_color => tag_bg_color)
67 71
 
68 72
     unless options[:skip_agents]
69 73
       created_agents = agent_diffs.map do |agent_diff|
@@ -76,17 +80,19 @@ class ScenarioImport
76 80
         agent.schedule = agent_diff.schedule.updated if agent_diff.schedule.present?
77 81
         agent.keep_events_for = agent_diff.keep_events_for.updated if agent_diff.keep_events_for.present?
78 82
         agent.propagate_immediately = agent_diff.propagate_immediately.updated if agent_diff.propagate_immediately.present? # == "true"
83
+        agent.service_id = agent_diff.service_id.updated if agent_diff.service_id.present?
79 84
         unless agent.save
80 85
           success = false
81 86
           errors.add(:base, "Errors when saving '#{agent_diff.name.incoming}': #{agent.errors.full_messages.to_sentence}")
82 87
         end
83 88
         agent
84 89
       end
85
-
86
-      links.each do |link|
87
-        receiver = created_agents[link['receiver']]
88
-        source = created_agents[link['source']]
89
-        receiver.sources << source unless receiver.sources.include?(source)
90
+      if success
91
+        links.each do |link|
92
+          receiver = created_agents[link['receiver']]
93
+          source = created_agents[link['source']]
94
+          receiver.sources << source unless receiver.sources.include?(source)
95
+        end
90 96
       end
91 97
     end
92 98
 
@@ -149,6 +155,9 @@ class ScenarioImport
149 155
           errors.add(:base, "Your updated options for '#{agent_data['name']}' were unparsable.")
150 156
         end
151 157
       end
158
+      if agent_diff.requires_service? && merges.present? && merges[index.to_s].present? && merges[index.to_s]['service_id'].present?
159
+        agent_diff.service_id = AgentDiff::FieldDiff.new(merges[index.to_s]['service_id'].to_i)
160
+      end
152 161
       agent_diff
153 162
     end
154 163
   end
@@ -192,6 +201,10 @@ class ScenarioImport
192 201
       @requires_merge
193 202
     end
194 203
 
204
+    def requires_service?
205
+      !!agent_instance.try(:oauthable?)
206
+    end
207
+
195 208
     def store!(agent_data)
196 209
       self.type = FieldDiff.new(agent_data["type"].split("::").pop)
197 210
       self.options = FieldDiff.new(agent_data['options'] || {})
@@ -252,5 +265,9 @@ class ScenarioImport
252 265
         key.gsub(/[^a-zA-Z0-9_-]/, '')
253 266
       end
254 267
     end
268
+
269
+    def agent_instance
270
+      "Agents::#{self.type.updated}".constantize.new
271
+    end
255 272
   end
256 273
 end

+ 89 - 0
app/models/service.rb

@@ -0,0 +1,89 @@
1
+class Service < ActiveRecord::Base
2
+  PROVIDER_TO_ENV_MAP = {'37signals' => 'THIRTY_SEVEN_SIGNALS'}
3
+
4
+  attr_accessible :provider, :name, :token, :secret, :refresh_token, :expires_at, :global, :options, :uid
5
+
6
+  serialize :options, Hash
7
+
8
+  belongs_to :user, :inverse_of => :services
9
+  has_many :agents, :inverse_of => :service
10
+
11
+  validates_presence_of :user_id, :provider, :name, :token
12
+
13
+  before_destroy :disable_agents
14
+
15
+  scope :available_to_user, lambda { |user| where("services.user_id = ? or services.global = true", user.id) }
16
+  scope :by_name, lambda { |dir = 'desc'| order("services.name #{dir}") }
17
+
18
+  def disable_agents(conditions = {})
19
+    agents.where.not(conditions[:where_not] || {}).each do |agent|
20
+      agent.service_id = nil
21
+      agent.disabled = true
22
+      agent.save!(validate: false)
23
+    end
24
+  end
25
+
26
+  def toggle_availability!
27
+    disable_agents(where_not: {user_id: self.user_id}) if global
28
+    self.global = !self.global
29
+    self.save!
30
+  end
31
+
32
+  def prepare_request
33
+    if expires_at && Time.now > expires_at
34
+      refresh_token!
35
+    end
36
+  end
37
+
38
+  def refresh_token!
39
+    response = HTTParty.post(endpoint, query: {
40
+                  type:          'refresh',
41
+                  client_id:     oauth_key,
42
+                  client_secret: oauth_secret,
43
+                  refresh_token: refresh_token
44
+    })
45
+    data = JSON.parse(response.body)
46
+    update(expires_at: Time.now + data['expires_in'], token: data['access_token'], refresh_token: data['refresh_token'].presence || refresh_token)
47
+  end
48
+
49
+  def endpoint
50
+    client_options = "OmniAuth::Strategies::#{OmniAuth::Utils.camelize(self.provider)}".constantize.default_options['client_options']
51
+    URI.join(client_options['site'], client_options['token_url'])
52
+  end
53
+
54
+  def provider_to_env
55
+    PROVIDER_TO_ENV_MAP[provider].presence || provider.upcase
56
+  end
57
+
58
+  def oauth_key
59
+    ENV["#{provider_to_env}_OAUTH_KEY"]
60
+  end
61
+
62
+  def oauth_secret
63
+    ENV["#{provider_to_env}_OAUTH_SECRET"]
64
+  end
65
+
66
+  def self.provider_specific_options(omniauth)
67
+    case omniauth['provider']
68
+      when 'twitter', 'github'
69
+        { name: omniauth['info']['nickname'] }
70
+      when '37signals'
71
+        { user_id: omniauth['extra']['accounts'][0]['id'], name: omniauth['info']['name'] }
72
+      else
73
+        { name: omniauth['info']['nickname'] }
74
+    end
75
+  end
76
+
77
+  def self.initialize_or_update_via_omniauth(omniauth)
78
+    options = provider_specific_options(omniauth)
79
+
80
+    find_or_initialize_by(provider: omniauth['provider'], uid: omniauth['uid'].to_s).tap do |service|
81
+      service.assign_attributes token: omniauth['credentials']['token'],
82
+                                secret: omniauth['credentials']['secret'],
83
+                                name: options[:name],
84
+                                refresh_token: omniauth['credentials']['refresh_token'],
85
+                                expires_at: omniauth['credentials']['expires_at'] && Time.at(omniauth['credentials']['expires_at']),
86
+                                options: options
87
+    end
88
+  end
89
+end

+ 5 - 0
app/models/user.rb

@@ -27,6 +27,11 @@ class User < ActiveRecord::Base
27 27
   has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user
28 28
   has_many :logs, :through => :agents, :class_name => "AgentLog"
29 29
   has_many :scenarios, :inverse_of => :user, :dependent => :destroy
30
+  has_many :services, -> { by_name('asc') }, :dependent => :destroy
31
+
32
+  def available_services
33
+    Service.available_to_user(self).by_name
34
+  end
30 35
 
31 36
   # Allow users to login via either email or username.
32 37
   def self.find_first_by_auth_conditions(warden_conditions)

+ 1 - 1
app/views/agents/_action_menu.html.erb

@@ -32,7 +32,7 @@
32 32
 
33 33
     <% agent.scenarios.each do |scenario| %>
34 34
       <li>
35
-        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from <span class='scenario label label-info'>#{h scenario.name}</span>".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %>
35
+        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from #{scenario_label(scenario)}".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %>
36 36
       </li>
37 37
     <% end %>
38 38
   <% end %>

+ 5 - 1
app/views/agents/_form.html.erb

@@ -25,11 +25,15 @@
25 25
             </div>
26 26
           <% end %>
27 27
 
28
-          <div class="form-group">
28
+          <div class="form-group type-select">
29 29
             <%= f.label :name %>
30 30
             <%= f.text_field :name, :class => 'form-control' %>
31 31
           </div>
32 32
 
33
+          <div class='oauthable-form'>
34
+            <%= render partial: 'oauth_dropdown' %>
35
+          </div>
36
+
33 37
           <div class="form-group">
34 38
             <%= f.label :schedule, :class => 'control-label' %>
35 39
             <div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>">

+ 6 - 0
app/views/agents/_oauth_dropdown.html.erb

@@ -0,0 +1,6 @@
1
+<% if @agent.try(:oauthable?) %>
2
+  <div class="form-group type-select">
3
+    <%= label_tag :service %>
4
+    <%= select_tag 'agent[service_id]', options_for_select(@agent.valid_services_for(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, @agent.service_id), class: 'form-control' %>
5
+  </div>
6
+<% end %>

+ 26 - 0
app/views/application/_upgrade_warning.html.erb

@@ -0,0 +1,26 @@
1
+<% if @twitter_agent || @basecamp_agent %>
2
+  <div class="alert alert-danger" role="alert">
3
+    <p>
4
+      <b>Warning!</b> You need to update your Huginn configuration, so your agents continue to work with the new OAuth services.
5
+    </p>
6
+    <br/>
7
+    <% if @twitter_agent %>
8
+      <p>
9
+        To complete the migration of your <b>Twitter</b> agents you need to update your .env file and add the following two lines:
10
+
11
+        <pre>
12
+TWITTER_OAUTH_KEY=<%= @twitter_oauth_key %>
13
+TWITTER_OAUTH_SECRET=<%= @twitter_oauth_secret %>
14
+        </pre>
15
+        To authenticate new accounts with your twitter OAuth application you need to log in the to <a href="https://apps.twitter.com/" target="_blank">twitter application management page</a> and set the callback URL of your application to "http<%= ENV['FORCE_SSL'] == 'true' ? 's' : '' %>://<%= ENV['DOMAIN'] %>/auth/twitter/callback".
16
+
17
+      </p>
18
+    <% end %>
19
+    <% if @basecamp_agent %>
20
+      <p>
21
+        Your <b>Basecamp</b> agents could not be migrated automatically. You need to manually register an application with 37signals and authenticate Huginn to use it.<br/>
22
+        Have a look at the <%= link_to 'Wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: '_blank' %> if you need help.
23
+      </p>
24
+    <% end %>
25
+  </div>
26
+<% end -%>

+ 1 - 0
app/views/layouts/_navigation.html.erb

@@ -22,6 +22,7 @@
22 22
       <%= nav_link "Scenarios", scenarios_path %>
23 23
       <%= nav_link "Events", events_path %>
24 24
       <%= nav_link "Credentials", user_credentials_path %>
25
+      <%= nav_link "Services", services_path %>
25 26
     </ul>
26 27
   <% end %>
27 28
   

+ 4 - 1
app/views/layouts/application.html.erb

@@ -24,7 +24,10 @@
24 24
           <%= render 'layouts/messages' %>
25 25
         </div>
26 26
       </div>
27
-      
27
+      <% if user_signed_in? %>
28
+        <%= render "upgrade_warning" %>
29
+      <% end %>
30
+
28 31
       <%= yield %>
29 32
       
30 33
     </div>

+ 13 - 3
app/views/scenario_imports/_step_two.html.erb

@@ -13,9 +13,8 @@
13 13
       <div class="alert alert-warning">
14 14
         <span class='glyphicon glyphicon-warning-sign'></span>
15 15
         This Scenario already exists in your system. The import will update your existing
16
-        <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario's title
17
-        and
18
-        description. Below you can customize how the individual agents get updated.
16
+        <%= scenario_label(@scenario_import.existing_scenario) %> Scenario's title,
17
+        description and tag colors. Below you can customize how the individual agents get updated.
19 18
       </div>
20 19
     <% end %>
21 20
 
@@ -120,6 +119,17 @@
120 119
           </div>
121 120
         <% end %>
122 121
       </div>
122
+
123
+      <% if agent_diff.requires_service? %>
124
+        <div class='row'>
125
+          <div class='col-md-4'>
126
+            <div class="form-group type-select">
127
+              <%= label_tag "scenario_import[merges][#{index}][service_id]", 'Service' %>
128
+              <%= select_tag "scenario_import[merges][#{index}][service_id]", options_for_select(agent_diff.agent_instance.valid_services_for(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, agent_diff.agent.try(:service_id)), class: 'form-control' %>
129
+            </div>
130
+          </div>
131
+        </div>
132
+      <% end %>
123 133
     </div>
124 134
   <% end %>
125 135
 </div>

+ 13 - 1
app/views/scenarios/_form.html.erb

@@ -15,6 +15,18 @@
15 15
         <%= f.text_field :name, :class => 'form-control', :placeholder => "Name your Scenario" %>
16 16
       </div>
17 17
     </div>
18
+    <div class="col-md-2">
19
+      <div class="form-group">
20
+        <%= f.label :tag_bg_color, "Tag Background Color" %>
21
+        <%= f.color_field :tag_bg_color, :class => 'form-control', :value => @scenario.tag_bg_color || default_scenario_bg_color %>
22
+      </div>
23
+    </div>
24
+    <div class="col-md-2">
25
+      <div class="form-group">
26
+        <%= f.label :tag_fg_color, "Tag Foreground Color" %>
27
+        <%= f.color_field :tag_fg_color, :class => 'form-control', :value => @scenario.tag_fg_color || default_scenario_fg_color %>
28
+      </div>
29
+    </div>
18 30
   </div>
19 31
 
20 32
   <div class="row">
@@ -54,4 +66,4 @@
54 66
       </div>
55 67
     </div>
56 68
   </div>
57
-<% end %>
69
+<% end %>

+ 2 - 1
app/views/scenarios/index.html.erb

@@ -21,6 +21,7 @@
21 21
         <% @scenarios.each do |scenario| %>
22 22
           <tr>
23 23
             <td>
24
+              <%= scenario_label(scenario, content_tag(:i, '', class: 'glyphicon glyphicon-font')) %>
24 25
               <%= link_to(scenario.name, scenario) %>
25 26
             </td>
26 27
             <td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td>
@@ -47,4 +48,4 @@
47 48
       </div>
48 49
     </div>
49 50
   </div>
50
-</div>
51
+</div>

+ 2 - 2
app/views/scenarios/share.html.erb

@@ -2,7 +2,7 @@
2 2
   <div class='row'>
3 3
     <div class='col-md-12'>
4 4
       <div class="page-header">
5
-        <h2>Share <span class='label label-info scenario'><%= @scenario.name %></span> with the world</h2>
5
+        <h2>Share <%= scenario_label(@scenario) %> with the world</h2>
6 6
       </div>
7 7
 
8 8
       <p>
@@ -30,4 +30,4 @@
30 30
       </div>
31 31
     </div>
32 32
   </div>
33
-</div>
33
+</div>

+ 2 - 1
app/views/scenarios/show.html.erb

@@ -2,7 +2,8 @@
2 2
   <div class='row'>
3 3
     <div class='col-md-12'>
4 4
       <div class="page-header">
5
-        <h2><span class='label label-info scenario'><%= @scenario.name %></span> <%= "Public" if @scenario.public? %> Scenario</h2>
5
+        <h2><%= scenario_label(@scenario) %> <%= "Public" if @scenario.public? %> Scenario</h2>
6
+
6 7
       </div>
7 8
 
8 9
       <% if @scenario.description.present? %>

+ 58 - 0
app/views/services/index.html.erb

@@ -0,0 +1,58 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Your Services
7
+        </h2>
8
+      </div>
9
+      <p>
10
+        Before you can authenticate with a service, you need to set it up. Have a look at the Huginn
11
+        <%= link_to 'wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: :_blank %>
12
+        for guidance.
13
+      </p>
14
+      <% if has_oauth_configuration_for('twitter') %>
15
+        <p><%= link_to "Authenticate with Twitter", "/auth/twitter" %></p>
16
+      <% end %>
17
+      <% if has_oauth_configuration_for('thirty_seven_signals') %>
18
+        <p><%= link_to "Authenticate with 37Signals (Basecamp)", "/auth/37signals" %></p>
19
+      <% end -%>
20
+      <% if has_oauth_configuration_for('github') %>
21
+        <p><%= link_to "Authenticate with Github", "/auth/github" %></p>
22
+      <% end -%>
23
+      <hr>
24
+
25
+      <div class='table-responsive'>
26
+        <table class='table table-striped events'>
27
+          <tr>
28
+            <th>Provider</th>
29
+            <th>Username</th>
30
+            <th>Global?</th>
31
+            <th></th>
32
+          </tr>
33
+
34
+        <% @services.each do |service| %>
35
+          <tr>
36
+            <td><%= service.provider %></td>
37
+            <td><%= service.name %></td>
38
+            <td><%= service.global ? 'Yes' : 'No' %></td>
39
+            <td>
40
+              <div class="btn-group btn-group-xs">
41
+                <% if service.global %>
42
+                  <%= link_to 'Make private', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to remove access to your data on this service for other users?'}, class: "btn btn-default" %>
43
+                <% else %>
44
+                   <%= link_to 'Make global', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to grant every user on this system access to your data on this service?'}, class: "btn btn-default" %>
45
+                <% end %>
46
+                <%= link_to 'Delete', service_path(service), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %>
47
+              </div>
48
+            </td>
49
+          </tr>
50
+        <% end %>
51
+        </table>
52
+      </div>
53
+
54
+      <%= paginate @services, :theme => 'twitter-bootstrap-3' %>
55
+    </div>
56
+  </div>
57
+</div>
58
+

+ 3 - 3
app/views/system_mailer/send_message.html.erb

@@ -5,14 +5,14 @@
5 5
   </head>
6 6
   <body>
7 7
     <% if @headline %>
8
-      <h1><%= @headline %></h1>
8
+      <h1><%= sanitize @headline %></h1>
9 9
     <% end %>
10 10
     <% @groups.each do |group| %>
11 11
       <div style='margin-bottom: 10px;'>
12
-        <div><%= group[:title] %></div>
12
+        <div><%= sanitize group[:title] %></div>
13 13
         <% group[:entries].each do |entry| %>
14 14
           <div style='margin-left: 10px;'>
15
-            <%= entry %>
15
+            <%= sanitize entry %>
16 16
           </div>
17 17
         <% end %>
18 18
       </div>

+ 5 - 0
config/initializers/omniauth.rb

@@ -0,0 +1,5 @@
1
+Rails.application.config.middleware.use OmniAuth::Builder do
2
+  provider :twitter, ENV['TWITTER_OAUTH_KEY'], ENV['TWITTER_OAUTH_SECRET'], authorize_params: {force_login: 'true', use_authorize: 'true'}
3
+  provider '37signals', ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'], ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET']
4
+  provider :github, ENV['GITHUB_OAUTH_KEY'], ENV['GITHUB_OAUTH_SECRET']
5
+end

+ 7 - 0
config/routes.rb

@@ -45,6 +45,12 @@ Huginn::Application.routes.draw do
45 45
 
46 46
   resources :user_credentials, :except => :show
47 47
 
48
+  resources :services, :only => [:index, :destroy] do
49
+    member do
50
+      post :toggle_availability
51
+    end
52
+  end
53
+
48 54
   get "/worker_status" => "worker_status#show"
49 55
 
50 56
   post "/users/:user_id/update_location/:secret" => "user_location_updates#create"
@@ -56,6 +62,7 @@ Huginn::Application.routes.draw do
56 62
 #  get "/delayed_job" => DelayedJobWeb, :anchor => false
57 63
 
58 64
   devise_for :users, :sign_out_via => [ :post, :delete ]
65
+  get '/auth/:provider/callback', to: 'services#callback'
59 66
 
60 67
   get "/about" => "home#about"
61 68
   root :to => "home#index"

+ 18 - 0
db/migrate/20140515211100_create_services.rb

@@ -0,0 +1,18 @@
1
+class CreateServices < ActiveRecord::Migration
2
+  def change
3
+    create_table :services do |t|
4
+      t.integer :user_id, null: false
5
+      t.string :provider, null: false
6
+      t.string :name, null: false
7
+      t.text :token, null: false
8
+      t.text :secret
9
+      t.text :refresh_token
10
+      t.datetime :expires_at
11
+      t.boolean :global, default: false
12
+      t.text :options
13
+      t.timestamps
14
+    end
15
+    add_index :services, :user_id
16
+    add_index :services, [:user_id, :global]
17
+  end
18
+end

+ 5 - 0
db/migrate/20140525150040_add_service_id_to_agents.rb

@@ -0,0 +1,5 @@
1
+class AddServiceIdToAgents < ActiveRecord::Migration
2
+  def change
3
+    add_column :agents, :service_id, :integer
4
+  end
5
+end

+ 61 - 0
db/migrate/20140525150140_migrate_agents_to_service_authentication.rb

@@ -0,0 +1,61 @@
1
+class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration
2
+  def twitter_consumer_key(agent)
3
+    agent.options['consumer_key'].presence || agent.credential('twitter_consumer_key')
4
+  end
5
+
6
+  def twitter_consumer_secret(agent)
7
+    agent.options['consumer_secret'].presence || agent.credential('twitter_consumer_secret')
8
+  end
9
+
10
+  def twitter_oauth_token(agent)
11
+    agent.options['oauth_token'].presence || agent.options['access_key'].presence || agent.credential('twitter_oauth_token')
12
+  end
13
+
14
+  def twitter_oauth_token_secret(agent)
15
+    agent.options['oauth_token_secret'].presence || agent.options['access_secret'].presence || agent.credential('twitter_oauth_token_secret')
16
+  end
17
+
18
+  def up
19
+    agents = Agent.where(type: ['Agents::TwitterUserAgent', 'Agents::TwitterStreamAgent', 'Agents::TwitterPublishAgent']).each do |agent|
20
+      service = agent.user.services.create!(
21
+        provider: 'twitter',
22
+        name: "Migrated '#{agent.name}'",
23
+        token: twitter_oauth_token(agent),
24
+        secret: twitter_oauth_token_secret(agent)
25
+      )
26
+      agent.service_id = service.id
27
+      agent.save!(validate: false)
28
+    end
29
+    migrated = false
30
+    if agents.length > 0
31
+      puts <<-EOF.strip_heredoc
32
+
33
+        Your Twitter agents were successfully migrated. You need to update your .env file and add the following two lines:
34
+
35
+        TWITTER_OAUTH_KEY=#{twitter_consumer_key(agents.first)}
36
+        TWITTER_OAUTH_SECRET=#{twitter_consumer_secret(agents.first)}
37
+
38
+        To authenticate new accounts with your twitter OAuth application you need to log in the to twitter application management page (https://apps.twitter.com/)
39
+        and set the callback URL of your application to "http#{ENV['FORCE_SSL'] == 'true' ? 's' : ''}://#{ENV['DOMAIN']}/auth/twitter/callback"
40
+
41
+      EOF
42
+      migrated = true
43
+    end
44
+    if Agent.where(type: ['Agents::BasecampAgent']).count > 0
45
+      puts <<-EOF.strip_heredoc
46
+
47
+        Your Basecamp agents can not be migrated automatically. You need to manually register an application with 37signals and authenticate Huginn to use it.
48
+        Have a look at the wiki (https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications) if you need help.
49
+
50
+
51
+      EOF
52
+      migrated = true
53
+    end
54
+    sleep 20 if migrated
55
+  end
56
+
57
+  def down
58
+    raise ActiveRecord::IrreversibleMigration, "Cannot revert migration to OAuth services"
59
+  end
60
+end
61
+

+ 5 - 0
db/migrate/20140809211540_remove_service_index_on_user_id.rb

@@ -0,0 +1,5 @@
1
+class RemoveServiceIndexOnUserId < ActiveRecord::Migration
2
+  def change
3
+    remove_index :services, :user_id
4
+  end
5
+end

+ 7 - 0
db/migrate/20140811200922_add_uid_column_to_services.rb

@@ -0,0 +1,7 @@
1
+class AddUidColumnToServices < ActiveRecord::Migration
2
+  def change
3
+    add_column :services, :uid, :string
4
+    add_index :services, :uid
5
+    add_index :services, :provider
6
+  end
7
+end

+ 6 - 0
db/migrate/20140820003139_add_tag_color_to_scenarios.rb

@@ -0,0 +1,6 @@
1
+class AddTagColorToScenarios < ActiveRecord::Migration
2
+  def change
3
+    add_column :scenarios, :tag_bg_color, :string
4
+    add_column :scenarios, :tag_fg_color, :string
5
+  end
6
+end

+ 59 - 34
db/schema.rb

@@ -13,19 +13,22 @@
13 13
 
14 14
 ActiveRecord::Schema.define(version: 20140822085519) do
15 15
 
16
+  # These are extensions that must be enabled in order to support this database
17
+  enable_extension "plpgsql"
18
+
16 19
   create_table "agent_logs", force: true do |t|
17
-    t.integer  "agent_id",                                       null: false
18
-    t.text     "message",           limit: 16777215,             null: false
19
-    t.integer  "level",                              default: 3, null: false
20
+    t.integer  "agent_id",                      null: false
21
+    t.text     "message",                       null: false
22
+    t.integer  "level",             default: 3, null: false
20 23
     t.integer  "inbound_event_id"
21 24
     t.integer  "outbound_event_id"
22
-    t.datetime "created_at",                                     null: false
23
-    t.datetime "updated_at",                                     null: false
25
+    t.datetime "created_at"
26
+    t.datetime "updated_at"
24 27
   end
25 28
 
26 29
   create_table "agents", force: true do |t|
27 30
     t.integer  "user_id"
28
-    t.text     "options",               limit: 16777215
31
+    t.text     "options"
29 32
     t.string   "type"
30 33
     t.string   "name"
31 34
     t.string   "schedule"
@@ -33,16 +36,17 @@ ActiveRecord::Schema.define(version: 20140822085519) do
33 36
     t.datetime "last_check_at"
34 37
     t.datetime "last_receive_at"
35 38
     t.integer  "last_checked_event_id"
36
-    t.datetime "created_at",                                               null: false
37
-    t.datetime "updated_at",                                               null: false
38
-    t.text     "memory",                limit: 2147483647
39
+    t.datetime "created_at"
40
+    t.datetime "updated_at"
41
+    t.text     "memory"
39 42
     t.datetime "last_web_request_at"
43
+    t.integer  "keep_events_for",       default: 0,     null: false
40 44
     t.datetime "last_event_at"
41 45
     t.datetime "last_error_log_at"
42
-    t.integer  "keep_events_for",                          default: 0,     null: false
43
-    t.boolean  "propagate_immediately",                    default: false, null: false
44
-    t.boolean  "disabled",                                 default: false, null: false
45
-    t.string   "guid",                                                     null: false
46
+    t.boolean  "propagate_immediately", default: false, null: false
47
+    t.boolean  "disabled",              default: false, null: false
48
+    t.string   "guid",                                  null: false
49
+    t.integer  "service_id"
46 50
   end
47 51
 
48 52
   add_index "agents", ["guid"], name: "index_agents_on_guid", using: :btree
@@ -61,17 +65,17 @@ ActiveRecord::Schema.define(version: 20140822085519) do
61 65
   add_index "chains", ["target_id"], name: "index_chains_on_target_id", using: :btree
62 66
 
63 67
   create_table "delayed_jobs", force: true do |t|
64
-    t.integer  "priority",                    default: 0
65
-    t.integer  "attempts",                    default: 0
66
-    t.text     "handler",    limit: 16777215
67
-    t.text     "last_error", limit: 16777215
68
+    t.integer  "priority",   default: 0
69
+    t.integer  "attempts",   default: 0
70
+    t.text     "handler"
71
+    t.text     "last_error"
68 72
     t.datetime "run_at"
69 73
     t.datetime "locked_at"
70 74
     t.datetime "failed_at"
71 75
     t.string   "locked_by"
72 76
     t.string   "queue"
73
-    t.datetime "created_at",                              null: false
74
-    t.datetime "updated_at",                              null: false
77
+    t.datetime "created_at"
78
+    t.datetime "updated_at"
75 79
   end
76 80
 
77 81
   add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree
@@ -79,11 +83,11 @@ ActiveRecord::Schema.define(version: 20140822085519) do
79 83
   create_table "events", force: true do |t|
80 84
     t.integer  "user_id"
81 85
     t.integer  "agent_id"
82
-    t.decimal  "lat",                           precision: 15, scale: 10
83
-    t.decimal  "lng",                           precision: 15, scale: 10
84
-    t.text     "payload",    limit: 2147483647
85
-    t.datetime "created_at",                                              null: false
86
-    t.datetime "updated_at",                                              null: false
86
+    t.decimal  "lat",        precision: 15, scale: 10
87
+    t.decimal  "lng",        precision: 15, scale: 10
88
+    t.text     "payload"
89
+    t.datetime "created_at"
90
+    t.datetime "updated_at"
87 91
     t.datetime "expires_at"
88 92
   end
89 93
 
@@ -94,8 +98,8 @@ ActiveRecord::Schema.define(version: 20140822085519) do
94 98
   create_table "links", force: true do |t|
95 99
     t.integer  "source_id"
96 100
     t.integer  "receiver_id"
97
-    t.datetime "created_at",                       null: false
98
-    t.datetime "updated_at",                       null: false
101
+    t.datetime "created_at"
102
+    t.datetime "updated_at"
99 103
     t.integer  "event_id_at_creation", default: 0, null: false
100 104
   end
101 105
 
@@ -113,24 +117,45 @@ ActiveRecord::Schema.define(version: 20140822085519) do
113 117
   add_index "scenario_memberships", ["scenario_id"], name: "index_scenario_memberships_on_scenario_id", using: :btree
114 118
 
115 119
   create_table "scenarios", force: true do |t|
116
-    t.string   "name",                        null: false
117
-    t.integer  "user_id",                     null: false
120
+    t.string   "name",                         null: false
121
+    t.integer  "user_id",                      null: false
118 122
     t.datetime "created_at"
119 123
     t.datetime "updated_at"
120 124
     t.text     "description"
121
-    t.boolean  "public",      default: false, null: false
122
-    t.string   "guid",                        null: false
125
+    t.boolean  "public",       default: false, null: false
126
+    t.string   "guid",                         null: false
123 127
     t.string   "source_url"
128
+    t.string   "tag_bg_color"
129
+    t.string   "tag_fg_color"
124 130
   end
125 131
 
126 132
   add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", unique: true, using: :btree
127 133
 
134
+  create_table "services", force: true do |t|
135
+    t.integer  "user_id",                       null: false
136
+    t.string   "provider",                      null: false
137
+    t.string   "name",                          null: false
138
+    t.text     "token",                         null: false
139
+    t.text     "secret"
140
+    t.text     "refresh_token"
141
+    t.datetime "expires_at"
142
+    t.boolean  "global",        default: false
143
+    t.text     "options"
144
+    t.datetime "created_at"
145
+    t.datetime "updated_at"
146
+    t.string   "uid"
147
+  end
148
+
149
+  add_index "services", ["provider"], name: "index_services_on_provider", using: :btree
150
+  add_index "services", ["uid"], name: "index_services_on_uid", using: :btree
151
+  add_index "services", ["user_id", "global"], name: "index_services_on_user_id_and_global", using: :btree
152
+
128 153
   create_table "user_credentials", force: true do |t|
129 154
     t.integer  "user_id",                           null: false
130 155
     t.string   "credential_name",                   null: false
131 156
     t.text     "credential_value",                  null: false
132
-    t.datetime "created_at",                        null: false
133
-    t.datetime "updated_at",                        null: false
157
+    t.datetime "created_at"
158
+    t.datetime "updated_at"
134 159
     t.string   "mode",             default: "text", null: false
135 160
   end
136 161
 
@@ -147,8 +172,8 @@ ActiveRecord::Schema.define(version: 20140822085519) do
147 172
     t.datetime "last_sign_in_at"
148 173
     t.string   "current_sign_in_ip"
149 174
     t.string   "last_sign_in_ip"
150
-    t.datetime "created_at",                             null: false
151
-    t.datetime "updated_at",                             null: false
175
+    t.datetime "created_at"
176
+    t.datetime "updated_at"
152 177
     t.boolean  "admin",                  default: false, null: false
153 178
     t.integer  "failed_attempts",        default: 0
154 179
     t.string   "unlock_token"

+ 3 - 1
lib/agents_exporter.rb

@@ -16,6 +16,8 @@ class AgentsExporter
16 16
       :description => options[:description].presence || 'No description provided',
17 17
       :source_url => options[:source_url],
18 18
       :guid => options[:guid],
19
+      :tag_fg_color => options[:tag_fg_color],
20
+      :tag_bg_color => options[:tag_bg_color],
19 21
       :exported_at => Time.now.utc.iso8601,
20 22
       :agents => agents.map { |agent| agent_as_json(agent) },
21 23
       :links => links
@@ -51,4 +53,4 @@ class AgentsExporter
51 53
       options[:propagate_immediately] = agent.propagate_immediately if agent.can_receive_events?
52 54
     end
53 55
   end
54
-end
56
+end

+ 35 - 0
spec/concerns/liquid_interpolatable_spec.rb

@@ -0,0 +1,35 @@
1
+require 'spec_helper'
2
+
3
+describe LiquidInterpolatable::Filters do
4
+  before do
5
+    @filter = Class.new do
6
+      include LiquidInterpolatable::Filters
7
+    end.new
8
+  end
9
+
10
+  describe 'uri_escape' do
11
+    it 'should escape a string for use in URI' do
12
+      @filter.uri_escape('abc:/?=').should == 'abc%3A%2F%3F%3D'
13
+    end
14
+  end
15
+
16
+  describe 'validations' do
17
+    class Agents::InterpolatableAgent < Agent
18
+      include LiquidInterpolatable
19
+
20
+      def check
21
+        create_event :payload => {}
22
+      end
23
+
24
+      def validate_options
25
+        interpolated['foo']
26
+      end
27
+    end
28
+
29
+    it "should finish without raising an exception" do
30
+      agent = Agents::InterpolatableAgent.new(name: "test", options: { 'foo' => '{{bar}' })
31
+      agent.valid?.should == false
32
+      agent.errors[:options].first.should =~ /not properly terminated/
33
+    end
34
+  end
35
+end

+ 2 - 0
spec/controllers/scenarios_controller_spec.rb

@@ -50,6 +50,8 @@ describe ScenariosController do
50 50
       assigns(:exporter).options[:description].should == scenarios(:bob_weather).description
51 51
       assigns(:exporter).options[:agents].should == scenarios(:bob_weather).agents
52 52
       assigns(:exporter).options[:guid].should == scenarios(:bob_weather).guid
53
+      assigns(:exporter).options[:tag_fg_color].should == scenarios(:bob_weather).tag_fg_color
54
+      assigns(:exporter).options[:tag_bg_color].should == scenarios(:bob_weather).tag_bg_color
53 55
       assigns(:exporter).options[:source_url].should be_falsey
54 56
       response.headers['Content-Disposition'].should == 'attachment; filename="bob-s-weather-alert-scenario.json"'
55 57
       response.headers['Content-Type'].should == 'application/json; charset=utf-8'

+ 58 - 0
spec/controllers/services_controller_spec.rb

@@ -0,0 +1,58 @@
1
+require 'spec_helper'
2
+
3
+describe ServicesController do
4
+  before do
5
+    sign_in users(:bob)
6
+    OmniAuth.config.test_mode = true
7
+    request.env["omniauth.auth"] = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/twitter.json')))
8
+  end
9
+
10
+  describe "GET index" do
11
+    it "only returns sevices of the current user" do
12
+      get :index
13
+      assigns(:services).all? {|i| i.user.should == users(:bob) }.should == true
14
+    end
15
+  end
16
+
17
+  describe "POST toggle_availability" do
18
+    it "should work for service of the user" do
19
+      post :toggle_availability, :id => services(:generic).to_param
20
+      assigns(:service).should eq(services(:generic))
21
+      redirect_to(services_path)
22
+    end
23
+
24
+    it "should not work for a service of another user" do
25
+      lambda {
26
+        post :toggle_availability, :id => services(:global).to_param
27
+      }.should raise_error(ActiveRecord::RecordNotFound)
28
+    end
29
+  end
30
+
31
+  describe "DELETE destroy" do
32
+    it "destroys only services owned by the current user" do
33
+      expect {
34
+        delete :destroy, :id => services(:generic).to_param
35
+      }.to change(Service, :count).by(-1)
36
+
37
+      lambda {
38
+        delete :destroy, :id => services(:global).to_param
39
+      }.should raise_error(ActiveRecord::RecordNotFound)
40
+    end
41
+  end
42
+
43
+  describe "accepting a callback url" do
44
+    it "should update the user's credentials" do
45
+      expect {
46
+        get :callback, provider: 'twitter'
47
+      }.to change { users(:bob).services.count }.by(1)
48
+    end
49
+
50
+    it "should work with an unknown provider (for now)" do
51
+      request.env["omniauth.auth"]['provider'] = 'unknown'
52
+      expect {
53
+        get :callback, provider: 'unknown'
54
+      }.to change { users(:bob).services.count }.by(1)
55
+      users(:bob).services.first.provider.should == 'unknown'
56
+    end
57
+  end
58
+end

+ 43 - 0
spec/data_fixtures/services/37signals.json

@@ -0,0 +1,43 @@
1
+{
2
+  "provider": "37signals",
3
+  "uid": 12345,
4
+  "info": {
5
+    "email": "basecamp@none.de",
6
+    "first_name": "Dominik",
7
+    "last_name": "Sander",
8
+    "name": "Dominik Sander"
9
+  },
10
+  "credentials": {
11
+    "token": "abcde",
12
+    "refresh_token": "fghrefresh",
13
+    "expires_at": 1401554352,
14
+    "expires": true
15
+  },
16
+  "extra": {
17
+    "accounts": [
18
+      {
19
+        "product": "bcx",
20
+        "name": "Dominik Sander's Basecamp",
21
+        "id": 12345,
22
+        "href": "https://basecamp.com/12345/api/v1"
23
+      }
24
+    ],
25
+    "raw_info": {
26
+      "expires_at": "2014-05-31T16:39:12Z",
27
+      "identity": {
28
+        "first_name": "Dominik",
29
+        "last_name": "Sander",
30
+        "email_address": "basecamp@none.de",
31
+        "id": 12345
32
+      },
33
+      "accounts": [
34
+        {
35
+          "product": "bcx",
36
+          "name": "Dominik Sander's Basecamp",
37
+          "id": 12345,
38
+          "href": "https://basecamp.com/12345/api/v1"
39
+        }
40
+      ]
41
+    }
42
+  }
43
+}

+ 52 - 0
spec/data_fixtures/services/github.json

@@ -0,0 +1,52 @@
1
+{
2
+  "provider": "github",
3
+  "uid": "12345",
4
+  "info": {
5
+    "nickname": "dsander",
6
+    "email": null,
7
+    "name": "Dominik Sander",
8
+    "image": "https://avatars.githubusercontent.com/u/12345?",
9
+    "urls": {
10
+      "GitHub": "https://github.com/dsander",
11
+      "Blog": "http://www.dsander.de"
12
+    }
13
+  },
14
+  "credentials": {
15
+    "token": "agithubtoken",
16
+    "expires": false
17
+  },
18
+  "extra": {
19
+    "raw_info": {
20
+      "login": "dsander",
21
+      "id": 12345,
22
+      "avatar_url": "https://avatars.githubusercontent.com/u/12345?",
23
+      "gravatar_id": "fsdfsdf",
24
+      "url": "https://api.github.com/users/dsander",
25
+      "html_url": "https://github.com/dsander",
26
+      "followers_url": "https://api.github.com/users/dsander/followers",
27
+      "following_url": "https://api.github.com/users/dsander/following{/other_user}",
28
+      "gists_url": "https://api.github.com/users/dsander/gists{/gist_id}",
29
+      "starred_url": "https://api.github.com/users/dsander/starred{/owner}{/repo}",
30
+      "subscriptions_url": "https://api.github.com/users/dsander/subscriptions",
31
+      "organizations_url": "https://api.github.com/users/dsander/orgs",
32
+      "repos_url": "https://api.github.com/users/dsander/repos",
33
+      "events_url": "https://api.github.com/users/dsander/events{/privacy}",
34
+      "received_events_url": "https://api.github.com/users/dsander/received_events",
35
+      "type": "User",
36
+      "site_admin": false,
37
+      "name": "Dominik Sander",
38
+      "company": null,
39
+      "blog": "http://www.url.de",
40
+      "location": null,
41
+      "email": null,
42
+      "hireable": false,
43
+      "bio": null,
44
+      "public_repos": 29,
45
+      "public_gists": 2,
46
+      "followers": 21,
47
+      "following": 9,
48
+      "created_at": "2008-08-17T18:17:50Z",
49
+      "updated_at": "2014-05-19T09:30:08Z"
50
+    }
51
+  }
52
+}

+ 66 - 0
spec/data_fixtures/services/twitter.json

@@ -0,0 +1,66 @@
1
+{
2
+  "provider": "twitter",
3
+  "uid": "123456",
4
+  "info": {
5
+    "nickname": "johnqpublic",
6
+    "name": "John Q Public",
7
+    "location": "Anytown, USA",
8
+    "image": "http://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png",
9
+    "description": "a very normal guy.",
10
+    "urls": {
11
+      "Website": null,
12
+      "Twitter": "https://twitter.com/johnqpublic"
13
+    }
14
+  },
15
+  "credentials": {
16
+    "token": "a1b2c3d4...",
17
+    "secret": "abcdef1234"
18
+  },
19
+  "extra": {
20
+    "access_token": "",
21
+    "raw_info": {
22
+      "name": "John Q Public",
23
+      "listed_count": 0,
24
+      "profile_sidebar_border_color": "181A1E",
25
+      "url": null,
26
+      "lang": "en",
27
+      "statuses_count": 129,
28
+      "profile_image_url": "http://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png",
29
+      "profile_background_image_url_https": "https://twimg0-a.akamaihd.net/profile_background_images/229171796/pattern_036.gif",
30
+      "location": "Anytown, USA",
31
+      "time_zone": "Chicago",
32
+      "follow_request_sent": false,
33
+      "id": 123456,
34
+      "profile_background_tile": true,
35
+      "profile_sidebar_fill_color": "666666",
36
+      "followers_count": 1,
37
+      "default_profile_image": false,
38
+      "screen_name": "",
39
+      "following": false,
40
+      "utc_offset": -3600,
41
+      "verified": false,
42
+      "favourites_count": 0,
43
+      "profile_background_color": "1A1B1F",
44
+      "is_translator": false,
45
+      "friends_count": 1,
46
+      "notifications": false,
47
+      "geo_enabled": true,
48
+      "profile_background_image_url": "http://twimg0-a.akamaihd.net/profile_background_images/229171796/pattern_036.gif",
49
+      "protected": false,
50
+      "description": "a very normal guy.",
51
+      "profile_link_color": "2FC2EF",
52
+      "created_at": "Thu Jul 4 00:00:00 +0000 2013",
53
+      "id_str": "123456",
54
+      "profile_image_url_https": "https://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png",
55
+      "default_profile": false,
56
+      "profile_use_background_image": false,
57
+      "entities": {
58
+        "description": {
59
+          "urls": []
60
+        }
61
+      },
62
+      "profile_text_color": "666666",
63
+      "contributors_enabled": false
64
+    }
65
+  }
66
+}

+ 5 - 0
spec/env.test

@@ -0,0 +1,5 @@
1
+APP_SECRET_TOKEN=notarealappsecrettoken
2
+TWITTER_OAUTH_KEY=twitteroauthkey
3
+TWITTER_OAUTH_SECRET=twitteroauthsecret
4
+THIRTY_SEVEN_SIGNALS_OAUTH_KEY=TESTKEY
5
+THIRTY_SEVEN_SIGNALS_OAUTH_SECRET=TESTSECRET

+ 12 - 0
spec/fixtures/agents.yml

@@ -109,3 +109,15 @@ bob_manual_event_agent:
109 109
   user: bob
110 110
   name: "Bob's event testing agent"
111 111
   guid: <%= SecureRandom.hex %>
112
+
113
+bob_basecamp_agent:
114
+  type: Agents::BasecampAgent
115
+  user: bob
116
+  service: generic
117
+  guid: <%= SecureRandom.hex %>
118
+
119
+jane_basecamp_agent:
120
+  type: Agents::BasecampAgent
121
+  user: jane
122
+  service: generic
123
+  guid: <%= SecureRandom.hex %>

+ 17 - 0
spec/fixtures/services.yml

@@ -0,0 +1,17 @@
1
+generic:
2
+  token: 1234token
3
+  secret: 56789secret
4
+  refresh_token: refresh12345
5
+  provider: testprovider
6
+  name: test
7
+  expires_at: <%= Time.parse("2015-01-01 00:00:00") %>
8
+  options: <%= { user_id: 12345 }.to_yaml.inspect %>
9
+  user: bob
10
+global:
11
+  token: 1234token
12
+  provider: testprovider
13
+  name: test
14
+  expires_at: <%= Time.parse("2015-01-01 00:00:00") %>
15
+  options: <%= { user_id: 12345 }.to_yaml.inspect %>
16
+  user: jane
17
+  global: true

+ 30 - 0
spec/helpers/scenario_helper_spec.rb

@@ -0,0 +1,30 @@
1
+require 'spec_helper'
2
+
3
+describe ScenarioHelper do
4
+  let(:scenario) { users(:bob).scenarios.build(name: 'Scene', tag_fg_color: '#AAAAAA', tag_bg_color: '#000000') }
5
+
6
+  describe '#style_colors' do
7
+    it 'returns a css style-formated version of the scenario foreground and background colors' do
8
+      style_colors(scenario).should == "color:#AAAAAA;background-color:#000000"
9
+    end
10
+
11
+    it 'defauls foreground and background colors' do
12
+      scenario.tag_fg_color = nil
13
+      scenario.tag_bg_color = nil
14
+      style_colors(scenario).should == "color:#FFFFFF;background-color:#5BC0DE"
15
+    end
16
+  end
17
+
18
+  describe '#scenario_label' do
19
+    it 'creates a scenario label with the scenario name' do
20
+      scenario_label(scenario).should ==
21
+        '<span class="label scenario" style="color:#AAAAAA;background-color:#000000">Scene</span>'
22
+    end
23
+
24
+    it 'creates a scenario label with the given text' do
25
+      scenario_label(scenario, 'Other').should ==
26
+        '<span class="label scenario" style="color:#AAAAAA;background-color:#000000">Other</span>'
27
+    end
28
+  end
29
+
30
+end

+ 8 - 2
spec/lib/agents_exporter_spec.rb

@@ -7,9 +7,13 @@ describe AgentsExporter do
7 7
     let(:name) { "My set of Agents" }
8 8
     let(:description) { "These Agents work together nicely!" }
9 9
     let(:guid) { "some-guid" }
10
+    let(:tag_fg_color) { "#ffffff" }
11
+    let(:tag_bg_color) { "#000000" }
10 12
     let(:source_url) { "http://yourhuginn.com/scenarios/2/export.json" }
11 13
     let(:agent_list) { [agents(:jane_weather_agent), agents(:jane_rain_notifier_agent)] }
12
-    let(:exporter) { AgentsExporter.new(:agents => agent_list, :name => name, :description => description, :source_url => source_url, :guid => guid) }
14
+    let(:exporter) { AgentsExporter.new(
15
+      :agents => agent_list, :name => name, :description => description, :source_url => source_url,
16
+      :guid => guid, :tag_fg_color => tag_fg_color, :tag_bg_color => tag_bg_color) }
13 17
 
14 18
     it "outputs a structure containing name, description, the date, all agents & their links" do
15 19
       data = exporter.as_json
@@ -17,6 +21,8 @@ describe AgentsExporter do
17 21
       data[:description].should == description
18 22
       data[:source_url].should == source_url
19 23
       data[:guid].should == guid
24
+      data[:tag_fg_color].should == tag_fg_color
25
+      data[:tag_bg_color].should == tag_bg_color
20 26
       Time.parse(data[:exported_at]).should be_within(2).of(Time.now.utc)
21 27
       data[:links].should == [{ :source => 0, :receiver => 1 }]
22 28
       data[:agents].should == agent_list.map { |agent| exporter.agent_as_json(agent) }
@@ -58,4 +64,4 @@ describe AgentsExporter do
58 64
       AgentsExporter.new(:name => ",,").filename.should == "exported-agents.json"
59 65
     end
60 66
   end
61
-end
67
+end

+ 11 - 26
spec/models/agents/basecamp_agent_spec.rb

@@ -1,17 +1,16 @@
1 1
 require 'spec_helper'
2
+require 'models/concerns/oauthable'
2 3
 
3 4
 describe Agents::BasecampAgent do
5
+  it_behaves_like Oauthable
6
+
4 7
   before(:each) do
5 8
     stub_request(:get, /json$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
6
-    stub_request(:get, /Z$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
7
-    @valid_params = {
8
-                      :username   => "user",
9
-                      :password   => "pass",
10
-                      :user_id    => 12345,
11
-                      :project_id => 6789,
12
-                    }
9
+    stub_request(:get, /02:00$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
10
+    @valid_params = { :project_id => 6789 }
13 11
 
14 12
     @checker = Agents::BasecampAgent.new(:name => "somename", :options => @valid_params)
13
+    @checker.service = services(:generic)
15 14
     @checker.user = users(:jane)
16 15
     @checker.save!
17 16
   end
@@ -21,21 +20,6 @@ describe Agents::BasecampAgent do
21 20
       @checker.should be_valid
22 21
     end
23 22
 
24
-    it "should require the basecamp username" do
25
-      @checker.options['username'] = nil
26
-      @checker.should_not be_valid
27
-    end
28
-
29
-    it "should require the basecamp password" do
30
-      @checker.options['password'] = nil
31
-      @checker.should_not be_valid
32
-    end
33
-
34
-    it "should require the basecamp user_id" do
35
-      @checker.options['user_id'] = nil
36
-      @checker.should_not be_valid
37
-    end
38
-
39 23
     it "should require the basecamp project_id" do
40 24
       @checker.options['project_id'] = nil
41 25
       @checker.should_not be_valid
@@ -45,7 +29,7 @@ describe Agents::BasecampAgent do
45 29
 
46 30
   describe "helpers" do
47 31
     it "should generate a correct request options hash" do
48
-      @checker.send(:request_options).should == {:basic_auth=>{:username=>"user", :password=>"pass"}, :headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}}
32
+      @checker.send(:request_options).should == {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => 'Bearer "1234token"'}}
49 33
     end
50 34
 
51 35
     it "should generate the currect request url" do
@@ -59,7 +43,7 @@ describe Agents::BasecampAgent do
59 43
 
60 44
     it "should provide the since attribute after the first run" do
61 45
       time = (Time.now-1.minute).iso8601
62
-      @checker.memory[:last_run] = time
46
+      @checker.memory[:last_event] = time
63 47
       @checker.save
64 48
       @checker.reload.send(:query_parameters).should == {:query => {:since => time}}
65 49
     end
@@ -67,9 +51,10 @@ describe Agents::BasecampAgent do
67 51
   describe "#check" do
68 52
     it "should not emit events on its first run" do
69 53
       expect { @checker.check }.to change { Event.count }.by(0)
54
+      expect(@checker.memory[:last_event]).to eq '2014-04-17T10:25:31.000+02:00'
70 55
     end
71 56
     it "should check that initial run creates an event" do
72
-      @checker.last_check_at = Time.now - 1.minute
57
+      @checker.memory[:last_event] = '2014-04-17T10:25:31.000+02:00'
73 58
       expect { @checker.check }.to change { Event.count }.by(1)
74 59
     end
75 60
   end
@@ -77,7 +62,7 @@ describe Agents::BasecampAgent do
77 62
   describe "#working?" do
78 63
     it "it is working when at least one event was emited" do
79 64
       @checker.should_not be_working
80
-      @checker.last_check_at = Time.now - 1.minute
65
+      @checker.memory[:last_event] = '2014-04-17T10:25:31.000+02:00'
81 66
       @checker.check
82 67
       @checker.reload.should be_working
83 68
     end

+ 1 - 0
spec/models/agents/twitter_publish_agent_spec.rb

@@ -13,6 +13,7 @@ describe Agents::TwitterPublishAgent do
13 13
     }
14 14
 
15 15
     @checker = Agents::TwitterPublishAgent.new(:name => "HuginnBot", :options => @opts)
16
+    @checker.service = services(:generic)
16 17
     @checker.user = users(:bob)
17 18
     @checker.save!
18 19
 

+ 1 - 0
spec/models/agents/twitter_stream_agent_spec.rb

@@ -13,6 +13,7 @@ describe Agents::TwitterStreamAgent do
13 13
     }
14 14
 
15 15
     @agent = Agents::TwitterStreamAgent.new(:name => "HuginnBot", :options => @opts)
16
+    @agent.service = services(:generic)
16 17
     @agent.user = users(:bob)
17 18
     @agent.save!
18 19
   end

+ 2 - 0
spec/models/agents/twitter_user_agent_spec.rb

@@ -16,6 +16,7 @@ describe Agents::TwitterUserAgent do
16 16
     }
17 17
 
18 18
     @checker = Agents::TwitterUserAgent.new(:name => "tectonic", :options => @opts)
19
+    @checker.service = services(:generic)
19 20
     @checker.user = users(:bob)
20 21
     @checker.save!
21 22
   end
@@ -31,6 +32,7 @@ describe Agents::TwitterUserAgent do
31 32
       opts = @opts.merge({ :starting_at => "Jan 01 00:00:01 +0000 2999", })
32 33
 
33 34
       checker = Agents::TwitterUserAgent.new(:name => "tectonic", :options => opts)
35
+      checker.service = services(:generic)
34 36
       checker.user = users(:bob)
35 37
       checker.save!
36 38
 

+ 17 - 1
spec/models/agents/website_agent_spec.rb

@@ -453,16 +453,32 @@ fire: hot
453 453
     end
454 454
 
455 455
     describe "#receive" do
456
-      it "should scrape from the url element in incoming event payload" do
456
+      before do
457 457
         @event = Event.new
458 458
         @event.agent = agents(:bob_rain_notifier_agent)
459 459
         @event.payload = { 'url' => "http://xkcd.com" }
460
+      end
460 461
 
462
+      it "should scrape from the url element in incoming event payload" do
461 463
         lambda {
462 464
           @checker.options = @valid_options
463 465
           @checker.receive([@event])
464 466
         }.should change { Event.count }.by(1)
465 467
       end
468
+
469
+      it "should interpolate values from incoming event payload" do
470
+        @event.payload['title'] = 'XKCD'
471
+
472
+        lambda {
473
+          @valid_options['extract']['site_title'] = {
474
+            'css' => "#comic img", 'value' => "'{{title}}'"
475
+          }
476
+          @checker.options = @valid_options
477
+          @checker.receive([@event])
478
+        }.should change { Event.count }.by(1)
479
+
480
+        Event.last.payload['site_title'].should == 'XKCD'
481
+      end
466 482
     end
467 483
   end
468 484
 

+ 29 - 0
spec/models/concerns/oauthable.rb

@@ -0,0 +1,29 @@
1
+require 'spec_helper'
2
+
3
+module Agents
4
+  class OauthableTestAgent < Agent
5
+    include Oauthable
6
+  end
7
+end
8
+
9
+shared_examples_for Oauthable do
10
+  before(:each) do
11
+    @agent = described_class.new(:name => "somename")
12
+    @agent.user = users(:jane)
13
+  end
14
+
15
+  it "should be oauthable" do
16
+    @agent.oauthable?.should == true
17
+  end
18
+
19
+  describe "valid_services_for" do
20
+    it "should return all available services without specifying valid_oauth_providers" do
21
+      @agent = Agents::OauthableTestAgent.new
22
+      @agent.valid_services_for(users(:bob)).collect(&:id).sort.should == [services(:generic), services(:global)].collect(&:id).sort
23
+    end
24
+
25
+    it "should filter the services based on the agent defaults" do
26
+      @agent.valid_services_for(users(:bob)).to_a.should == Service.where(provider: @agent.valid_oauth_providers)
27
+    end
28
+  end
29
+end

+ 9 - 0
spec/models/event_spec.rb

@@ -102,6 +102,15 @@ describe EventDrop do
102 102
     interpolate(t, @event).should eq('some title: http://some.site.example.org/')
103 103
   end
104 104
 
105
+  it 'should use created_at from the payload if it exists' do
106
+    created_at = @event.created_at - 86400
107
+    # Avoid timezone issue by using %s
108
+    @event.payload['created_at'] = created_at.strftime("%s")
109
+    @event.save!
110
+    t = '{{created_at | date:"%s" }}'
111
+    interpolate(t, @event).should eq(created_at.strftime("%s"))
112
+  end
113
+
105 114
   it 'should be iteratable' do
106 115
     # to_liquid returns self
107 116
     t = "{% for pair in to_liquid %}{{pair | join:':' }}\n{% endfor %}"

+ 66 - 3
spec/models/scenario_import_spec.rb

@@ -3,6 +3,8 @@ require 'spec_helper'
3 3
 describe ScenarioImport do
4 4
   let(:user) { users(:bob) }
5 5
   let(:guid) { "somescenarioguid" }
6
+  let(:tag_fg_color) { "#ffffff" }
7
+  let(:tag_bg_color) { "#000000" }
6 8
   let(:description) { "This is a cool Huginn Scenario that does something useful!" }
7 9
   let(:name) { "A useful Scenario" }
8 10
   let(:source_url) { "http://example.com/scenarios/2/export.json" }
@@ -45,11 +47,25 @@ describe ScenarioImport do
45 47
       :options => trigger_agent_options
46 48
     }
47 49
   end
50
+  let(:valid_parsed_basecamp_agent_data) do
51
+    {
52
+      :type => "Agents::BasecampAgent",
53
+      :name => "Basecamp test",
54
+      :schedule => "every_2m",
55
+      :keep_events_for => 0,
56
+      :propagate_immediately => true,
57
+      :disabled => false,
58
+      :guid => "a-basecamp-agent",
59
+      :options => {project_id: 12345}
60
+    }
61
+  end
48 62
   let(:valid_parsed_data) do
49
-    { 
63
+    {
50 64
       :name => name,
51 65
       :description => description,
52 66
       :guid => guid,
67
+      :tag_fg_color => tag_fg_color,
68
+      :tag_bg_color => tag_bg_color,
53 69
       :source_url => source_url,
54 70
       :exported_at => 2.days.ago.utc.iso8601,
55 71
       :agents => [
@@ -142,7 +158,7 @@ describe ScenarioImport do
142 158
       end
143 159
     end
144 160
   end
145
-  
161
+
146 162
   describe "#dangerous?" do
147 163
     it "returns false on most Agents" do
148 164
       ScenarioImport.new(:data => valid_data).should_not be_dangerous
@@ -171,6 +187,8 @@ describe ScenarioImport do
171 187
           scenario_import.scenario.name.should == name
172 188
           scenario_import.scenario.description.should == description
173 189
           scenario_import.scenario.guid.should == guid
190
+          scenario_import.scenario.tag_fg_color.should == tag_fg_color
191
+          scenario_import.scenario.tag_bg_color.should == tag_bg_color
174 192
           scenario_import.scenario.source_url.should == source_url
175 193
           scenario_import.scenario.public.should be_falsey
176 194
         end
@@ -269,6 +287,8 @@ describe ScenarioImport do
269 287
 
270 288
           existing_scenario.reload
271 289
           existing_scenario.guid.should == guid
290
+          existing_scenario.tag_fg_color.should == tag_fg_color
291
+          existing_scenario.tag_bg_color.should == tag_bg_color
272 292
           existing_scenario.description.should == description
273 293
           existing_scenario.name.should == name
274 294
           existing_scenario.source_url.should == source_url
@@ -407,5 +427,48 @@ describe ScenarioImport do
407 427
         end
408 428
       end
409 429
     end
430
+
431
+    context "agents which require a service" do
432
+      let(:valid_parsed_services) do
433
+        data = valid_parsed_data
434
+        data[:agents] = [valid_parsed_basecamp_agent_data,
435
+                         valid_parsed_trigger_agent_data]
436
+        data
437
+      end
438
+
439
+      let(:valid_parsed_services_data) { valid_parsed_services.to_json }
440
+
441
+      let(:services_scenario_import) {
442
+        _import = ScenarioImport.new(:data => valid_parsed_services_data)
443
+        _import.set_user users(:bob)
444
+        _import
445
+      }
446
+
447
+      describe "#generate_diff" do
448
+        it "should check if the agent requires a service" do
449
+          agent_diffs = services_scenario_import.agent_diffs
450
+          basecamp_agent_diff = agent_diffs[0]
451
+          basecamp_agent_diff.requires_service?.should == true
452
+        end
453
+
454
+        it "should add an error when no service is selected" do
455
+          services_scenario_import.import.should == false
456
+          services_scenario_import.errors[:base].length.should == 1
457
+        end
458
+      end
459
+
460
+      describe "#import" do
461
+        it "should import" do
462
+          services_scenario_import.merges = {
463
+            "0" => {
464
+              "service_id" => "0",
465
+            }
466
+          }
467
+          lambda {
468
+            services_scenario_import.import.should == true
469
+          }.should change { users(:bob).agents.count }.by(2)
470
+        end
471
+      end
472
+    end
410 473
   end
411
-end
474
+end

+ 24 - 0
spec/models/scenario_spec.rb

@@ -20,6 +20,30 @@ describe Scenario do
20 20
       new_instance.should_not be_valid
21 21
     end
22 22
 
23
+    it "validates tag_fg_color is hex color" do
24
+      new_instance.tag_fg_color = '#N07H3X'
25
+      new_instance.should_not be_valid
26
+      new_instance.tag_fg_color = '#BADA55'
27
+      new_instance.should be_valid
28
+    end
29
+
30
+    it "allows nil tag_fg_color" do
31
+      new_instance.tag_fg_color = nil
32
+      new_instance.should be_valid
33
+    end
34
+
35
+    it "validates tag_bg_color is hex color" do
36
+      new_instance.tag_bg_color = '#N07H3X'
37
+      new_instance.should_not be_valid
38
+      new_instance.tag_bg_color = '#BADA55'
39
+      new_instance.should be_valid
40
+    end
41
+
42
+    it "allows nil tag_bg_color" do
43
+      new_instance.tag_bg_color = nil
44
+      new_instance.should be_valid
45
+    end
46
+
23 47
     it "only allows Agents owned by user" do
24 48
       new_instance.agent_ids = [agents(:bob_website_agent).id]
25 49
       new_instance.should be_valid

+ 129 - 0
spec/models/service_spec.rb

@@ -0,0 +1,129 @@
1
+require 'spec_helper'
2
+
3
+describe Service do
4
+  before(:each) do
5
+    @user = users(:bob)
6
+  end
7
+
8
+  describe "#toggle_availability!" do
9
+    it "should toggle the global flag" do
10
+      @service = services(:generic)
11
+      @service.global.should == false
12
+      @service.toggle_availability!
13
+      @service.global.should == true
14
+      @service.toggle_availability!
15
+      @service.global.should == false
16
+    end
17
+
18
+    it "disconnects agents and disables them if the previously global service is made private again", focus: true do
19
+      agent = agents(:bob_basecamp_agent)
20
+      jane_agent = agents(:jane_basecamp_agent)
21
+
22
+      service = agent.service
23
+      service.toggle_availability!
24
+      service.agents.length.should == 2
25
+
26
+      service.toggle_availability!
27
+      jane_agent.reload
28
+      jane_agent.service_id.should be_nil
29
+      jane_agent.disabled.should be true
30
+
31
+      service.reload
32
+      service.agents.length.should == 1
33
+    end
34
+  end
35
+
36
+  it "disables all agents before beeing destroyed" do
37
+    agent = agents(:bob_basecamp_agent)
38
+    service = agent.service
39
+    service.destroy
40
+    agent.reload
41
+    agent.service_id.should be_nil
42
+    agent.disabled.should be true
43
+  end
44
+
45
+  describe "preparing for a request" do
46
+    before(:each) do
47
+      @service = services(:generic)
48
+    end
49
+
50
+    it "should not update the token if the token never expires" do
51
+      @service.expires_at = nil
52
+      @service.prepare_request.should == nil
53
+    end
54
+
55
+    it "should not update the token if the token is still valid" do
56
+      @service.expires_at = Time.now + 1.hour
57
+      @service.prepare_request.should == nil
58
+    end
59
+
60
+    it "should call refresh_token! if the token expired" do
61
+      stub(@service).refresh_token! { @service }
62
+      @service.expires_at = Time.now - 1.hour
63
+      @service.prepare_request.should == @service
64
+    end
65
+  end
66
+
67
+  describe "updating the access token" do
68
+    before(:each) do
69
+      @service = services(:generic)
70
+    end
71
+
72
+    it "should return the correct endpoint" do
73
+      @service.provider = '37signals'
74
+      @service.send(:endpoint).to_s.should == "https://launchpad.37signals.com/authorization/token"
75
+    end
76
+
77
+    it "should update the token" do
78
+      stub_request(:post, "https://launchpad.37signals.com/authorization/token?client_id=TESTKEY&client_secret=TESTSECRET&refresh_token=refreshtokentest&type=refresh").
79
+        to_return(:status => 200, :body => '{"expires_in":1209600,"access_token": "NEWTOKEN"}', :headers => {})
80
+      @service.provider = '37signals'
81
+      @service.refresh_token = 'refreshtokentest'
82
+      @service.refresh_token!
83
+      @service.token.should == 'NEWTOKEN'
84
+    end
85
+  end
86
+
87
+  describe "creating services via omniauth" do
88
+    it "should work with twitter services" do
89
+      twitter = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/twitter.json')))
90
+      expect {
91
+        service = @user.services.initialize_or_update_via_omniauth(twitter)
92
+        service.save!
93
+      }.to change { @user.services.count }.by(1)
94
+      service = @user.services.first
95
+      service.name.should == 'johnqpublic'
96
+      service.uid.should == '123456'
97
+      service.provider.should == 'twitter'
98
+      service.token.should == 'a1b2c3d4...'
99
+      service.secret.should == 'abcdef1234'
100
+    end
101
+    it "should work with 37signals services" do
102
+      signals = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/37signals.json')))
103
+      expect {
104
+        service = @user.services.initialize_or_update_via_omniauth(signals)
105
+        service.save!
106
+      }.to change { @user.services.count }.by(1)
107
+      service = @user.services.first
108
+      service.provider.should == '37signals'
109
+      service.name.should == 'Dominik Sander'
110
+      service.token.should == 'abcde'
111
+      service.uid.should == '12345'
112
+      service.refresh_token.should == 'fghrefresh'
113
+      service.options[:user_id].should == 12345
114
+      service.expires_at = Time.at(1401554352)
115
+    end
116
+    it "should work with github services" do
117
+      signals = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/github.json')))
118
+      expect {
119
+        service = @user.services.initialize_or_update_via_omniauth(signals)
120
+        service.save!
121
+      }.to change { @user.services.count }.by(1)
122
+      service = @user.services.first
123
+      service.provider.should == 'github'
124
+      service.name.should == 'dsander'
125
+      service.uid.should == '12345'
126
+      service.token.should == 'agithubtoken'
127
+    end
128
+  end
129
+end

+ 7 - 2
spec/spec_helper.rb

@@ -1,4 +1,3 @@
1
-# This file is copied to spec/ when you run 'rails generate rspec:install'
2 1
 ENV["RAILS_ENV"] ||= 'test'
3 2
 
4 3
 if ENV['COVERAGE']
@@ -9,6 +8,10 @@ else
9 8
   Coveralls.wear!('rails')
10 9
 end
11 10
 
11
+# Required ENV variables that are normally set in .env are setup here for the test environment.
12
+require 'dotenv'
13
+Dotenv.overload File.join(File.dirname(__FILE__), "env.test")
14
+
12 15
 require File.expand_path("../../config/environment", __FILE__)
13 16
 require 'rspec/rails'
14 17
 require 'rspec/autorun'
@@ -19,7 +22,9 @@ WebMock.disable_net_connect!
19 22
 
20 23
 # Requires supporting ruby files with custom matchers and macros, etc,
21 24
 # in spec/support/ and its subdirectories.
22
-Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f}
25
+Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
26
+
27
+ActiveRecord::Migration.maintain_test_schema!
23 28
 
24 29
 RSpec.configure do |config|
25 30
   config.mock_with :rr